├── .eslintrc.json ├── .gitignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── data ├── logo.svg └── onboarding_map.jam ├── dockerfile ├── docs ├── development.md ├── docs │ ├── 01-introduction.md │ ├── 02-getting-started │ │ ├── _category_.json │ │ ├── install-HA-addon.md │ │ └── install-standalone.md │ └── 04-code-samples │ │ ├── _category_.json │ │ ├── multiple-components.md │ │ └── multiple-devices.md ├── docusaurus.config.ts ├── package.json ├── readme.md ├── sidebars.ts ├── src │ └── css │ │ └── custom.css ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.ico │ │ ├── logo.svg │ │ └── screenshot.png ├── tsconfig.json └── yarn.lock ├── examples ├── .lib │ ├── header.eta │ └── my-device.eta ├── Bathroom │ └── index.eta ├── Kitchen │ └── index.eta ├── Living Room │ └── index.eta ├── docker-compose.yaml └── plc │ ├── .lib │ └── plc.eta │ ├── plc.eta │ └── uart.yaml ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── public └── favicon.ico ├── setup.mts ├── src ├── app │ ├── api │ │ ├── api-types.ts │ │ ├── device │ │ │ ├── [device_id] │ │ │ │ ├── esphome │ │ │ │ │ ├── compile │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── install │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── log │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── local │ │ │ │ │ ├── [path] │ │ │ │ │ │ ├── compiled │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── rename_to │ │ │ │ │ │ │ └── [new_name] │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ ├── test-data │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── toggle_enabled │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── ping │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ └── status │ │ │ └── route.ts │ ├── client-layout.tsx │ ├── components │ │ ├── devices-tree │ │ │ ├── device-toolbar.tsx │ │ │ ├── index.tsx │ │ │ ├── menus.tsx │ │ │ └── utils.ts │ │ ├── dialogs │ │ │ ├── about-dialog.tsx │ │ │ ├── confirmation-dialog.tsx │ │ │ └── input-text-dialog.tsx │ │ ├── editors │ │ │ ├── diff-editor.tsx │ │ │ ├── html-preview.tsx │ │ │ ├── log-stream.tsx │ │ │ ├── monaco │ │ │ │ ├── actions.ts │ │ │ │ ├── esphome-language.ts │ │ │ │ ├── languages.ts │ │ │ │ ├── monaco-init.ts │ │ │ │ └── useMonacoTheme.ts │ │ │ └── single-editor.tsx │ │ ├── onboarding │ │ │ ├── components.tsx │ │ │ ├── flowers.tsx │ │ │ ├── home.tsx │ │ │ └── index.tsx │ │ ├── panels-container.tsx │ │ ├── panels │ │ │ ├── devices-panel.tsx │ │ │ ├── diff-panel.tsx │ │ │ ├── esphome-compile-panel.tsx │ │ │ ├── esphome-device-panel.tsx │ │ │ ├── esphome-install-panel.tsx │ │ │ ├── esphome-log-panel.tsx │ │ │ ├── local-device-panel.tsx │ │ │ ├── local-file-panel.tsx │ │ │ ├── log-list.tsx │ │ │ └── query-wrapper.tsx │ │ └── toolbar.tsx │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ ├── stores │ │ ├── devices-store.ts │ │ ├── events.ts │ │ ├── index.ts │ │ ├── panels-store.ts │ │ ├── panels-store │ │ │ ├── device-diff-store.ts │ │ │ ├── esphome-compile-store.ts │ │ │ ├── esphome-device-store.ts │ │ │ ├── esphome-install-store.ts │ │ │ ├── esphome-log-store.ts │ │ │ ├── local-device-store.ts │ │ │ ├── local-file-store.ts │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ ├── query-utils.ts │ │ │ │ └── streaming-store.ts │ │ ├── status-store.ts │ │ └── status-store.tsx │ └── utils │ │ ├── api-client.ts │ │ ├── const.ts │ │ ├── file-utils.tsx │ │ └── hooks.ts ├── assets │ ├── etajs-logo.svg │ ├── logo.svg │ └── onboarding │ │ └── map.png ├── instrumentation.server.ts ├── instrumentation.ts ├── middleware.ts ├── server │ ├── config.ts │ ├── devices │ │ ├── esphome │ │ │ ├── client.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── local │ │ │ ├── compiler.ts │ │ │ ├── files.ts │ │ │ ├── index.ts │ │ │ ├── manifest-utils.ts │ │ │ ├── template-processors │ │ │ │ ├── eta.ts │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ ├── yaml-merger.test.ts │ │ │ │ ├── yaml-merger.ts │ │ │ │ ├── yaml-patcher.test.ts │ │ │ │ └── yaml-patcher.ts │ │ │ └── utils.ts │ │ └── types.ts │ ├── utils │ │ ├── fs-utils.ts │ │ ├── ha-client.ts │ │ └── yaml-utils.ts │ └── yamlpath │ │ ├── index.test.ts │ │ ├── index.ts │ │ └── test-sample1.yaml └── shared │ ├── http-utils.ts │ └── log.ts ├── tasks.mts ├── tsconfig.json ├── vitest.config.ts └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "next/typescript" 5 | ], 6 | "ignorePatterns": [ 7 | "src/3rd-party" 8 | ], 9 | "rules": { 10 | //"import/no-cycle": "error", 11 | "no-explicit-any": "off", 12 | "no-unused-vars": "off", 13 | "@typescript-eslint/no-unused-vars": "off", 14 | "@typescript-eslint/no-explicit-any": "off" 15 | } 16 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | /docs/build 24 | /docs/.docusaurus 25 | 26 | # misc 27 | .DS_Store 28 | *.pem 29 | 30 | # debug 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | 35 | # env files (can opt-in for commiting if needed) 36 | .env* 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | next-env.d.ts 44 | .vs 45 | 46 | public/esphome_schemas/ 47 | src/3rd-party/esphome-dashboard/ 48 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug server-side", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "yarn dev" 9 | }, 10 | { 11 | "name": "Next.js: debug client-side", 12 | "type": "chrome", 13 | "request": "launch", 14 | "url": "http://localhost:3000" 15 | }, 16 | { 17 | "name": "Next.js: debug full stack", 18 | "type": "node", 19 | "request": "launch", 20 | "program": "${workspaceFolder}/node_modules/.bin/next", 21 | "runtimeArgs": ["--inspect"], 22 | "skipFiles": ["/**"], 23 | "serverReadyAction": { 24 | "action": "debugWithEdge", 25 | "killOnServerStop": true, 26 | "pattern": "- Local:.+(https?://.+)", 27 | "uriFormat": "%s", 28 | "webRoot": "${workspaceFolder}" 29 | } 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.3.0] 4 | - Renamed to "Editor for ESPHome" 5 | - Minor improvements 6 | 7 | ## [0.2.0] 8 | - Dark mode support 9 | 10 | ## [0.1.0] 11 | - HA Add-on 12 | - Minor UI improvements -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Editor for ESPHome 2 | 3 | Editor for ESPHome is a self-hosted, open-source, offline code editor built on top of [ESPHome](https://esphome.io/). It's designed to simplify the configuration of ESPHome devices by streamlining the process of writing and managing repetitive sections of your configuration YAML files. 4 | 5 | See [Documentation](https://editor-4-esphome.github.io/) for more info and how to install 6 | 7 |

8 | 9 |

10 | 11 | ## Roadmap (unordered) 12 | - File management via Web UI 13 | - Syntax/error highlighting 14 | - YAML Patches 15 | - Versioning/Alternates 16 | -------------------------------------------------------------------------------- /data/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 108 | -------------------------------------------------------------------------------- /data/onboarding_map.jam: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morcatko/EspHome-Editor/e39ff003c71ea6d8d7a21670eb7b20d3d95f7800/data/onboarding_map.jam -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM node:current-alpine AS builder 2 | WORKDIR /build 3 | 4 | #COPY .npmrc ./ 5 | COPY .eslintrc.json ./ 6 | COPY next-env.d.ts ./ 7 | COPY next.config.ts ./ 8 | COPY package.json ./ 9 | COPY postcss.config.mjs ./ 10 | COPY setup.mts ./ 11 | COPY tsconfig.json ./ 12 | COPY vitest.config.ts ./ 13 | COPY yarn.lock ./ 14 | 15 | RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn --frozen-lockfile 16 | 17 | COPY src ./src 18 | COPY public ./public 19 | RUN yarn setup 20 | RUN yarn test run 21 | RUN yarn build 22 | 23 | FROM node:current-alpine AS run 24 | 25 | WORKDIR /app 26 | COPY examples /app/work-folder/devices 27 | COPY --from=builder /build/.next/standalone ./ 28 | COPY --from=builder /build/.next/static ./.next/static 29 | COPY --from=builder /build/public ./public 30 | 31 | EXPOSE 3000 32 | ENV PORT=3000 33 | CMD HOSTNAME="0.0.0.0" node server.js -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ```bash 4 | yarn install 5 | yarn setup # To prepare dev environment 6 | ---------- 7 | yarn dev # Start the dev server 8 | --- or --- 9 | yarn build # Build a production version 10 | yarn preview # Preview the production build 11 | ``` 12 | 13 | If websockets (log, compile, ... streaming) do not work then run `yarn` to reinstall `next-ws` -------------------------------------------------------------------------------- /docs/docs/01-introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | slug: / 4 | --- 5 | 6 | # Introduction 7 | 8 | import { BeakerIcon, CodeIcon, DownloadIcon, GitCompareIcon, LogIcon, UploadIcon } from "@primer/octicons-react"; 9 | import { color_local, color_gray, color_esphome, color_online, color_offline } from "@site/../src/app/utils/const"; 10 | 11 | 12 | **Editor for ESPHome** is a self-hosted, open-source, and fully offline code editor built specifically for working with projects. 13 | 14 | It simplifies the process of configuring ESPHome devices by providing an intuitive interface for writing, editing, and managing YAML configuration files — especially those with repetitive or complex sections. 15 | 16 | Whether you're a seasoned home automation enthusiast or just getting started with ESP-based devices, this editor helps you: 17 | 18 | - **Speed up development** by reducing repetitive configuration tasks 19 | - **Stay offline and in control** with a local, self-hosted setup 20 | 21 | Once Editor for ESPHome is running, you'll be greeted with a clean and simple interface focused on managing and editing ESPHome device configurations. 22 | 23 | 24 | ![Screenshot](@site/static/img/screenshot.png) 25 | 26 | ## 📂 Sidebar – Device List 27 | 28 | The sidebar on the left shows all devices currently stored in your editor's configuration folder and in ESPHome. From here, you can: 29 | 30 | - View Device Status indicated by the color of the light bulb 31 | - Gray - Editor-only device 32 | - Online/Offline - status of ESPHome device 33 | - Add a new device 34 | - Edit device configuration 35 | 36 | 37 | 38 | ## 📝 Editor Panel 39 | 40 | The main area of the screen is the code editor. This is where you write or modify the configuration for the selected device. 41 | 42 | Features include: 43 | 44 | - Syntax highlighting for YAML 45 | - Auto-indentation and smart formatting 46 | - Quick navigation to ESPHome component documentation 47 | 48 | ## 🔧 Device Actions 49 | 50 | At the top of the editor, you’ll find actions specific to the currently selected device: 51 | 52 | - Import configuration from ESPHome instance 53 | - View the compiled local ESPHome configuration 54 | - Compare local vs. ESPHome configuration 55 | - Upload local configuration to ESPHome 56 | - View ESPHome configuration 57 | - Compile ESPHome configuration 58 | - Install configuration to a device 59 | - View log stream 60 | 61 | --- 62 | In the next section, we'll walk you through how to get started with installing and running Editor for ESPHome on your own machine. -------------------------------------------------------------------------------- /docs/docs/02-getting-started/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Getting Started", 3 | "position": 2, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Get up and running in less then 5 minutes" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/02-getting-started/install-HA-addon.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Installation – HA Add-on 6 | 7 | The recommended way to use **Editor for ESPHome** is through the official Home Assistant add-on. 8 | 9 | ### Step 1: Add the Add-on Repository 10 | 11 | You can add the repository in one of two ways: 12 | 13 | - **Click this badge to add it directly to your Home Assistant instance:** 14 | [![Add Add-on Repository](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2FMorcatko%2Fha-addons) 15 | 16 | - **Or add it manually:** 17 | Go to **Settings → Add-ons → Add-on Store → ⋮ → Repositories**, and enter: `https://github.com/Morcatko/ha-addons` 18 | 19 | ### Step 2: Install the Editor for ESPHome Add-on 20 | 21 | Once the repository is added, find **Editor for ESPHome** in the list and click **Install**. 22 | 23 | After installation, you can start the add-on and open the editor from the Home Assistant sidebar. 24 | -------------------------------------------------------------------------------- /docs/docs/02-getting-started/install-standalone.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Installation – Standalone Docker 6 | 7 | You can also run **Editor for ESPHome** as a standalone, self-hosted Docker app. It connects to your existing ESPHome dashboard and stores editor-specific device configurations separately. 8 | 9 | ### Quick Demo 10 | 11 | If you just want to try it out without setting anything up, run the following command: 12 | 13 | ```bash 14 | docker run -d -p 8080:3000 morcatko/esphome-editor 15 | ``` 16 | This will launch the editor on port 8080 with some sample devices preloaded. 17 | 18 | ### Full Setup with Docker Compose 19 | To use Editor for ESPHome with your own ESPHome instance and device configurations, follow these steps: 20 | 21 | Create a folder to store editor device configurations (e.g. `/home/esphome-editor/devices` - ⚠️ This is not the folder where your ESPHome config files live.). 22 | 23 | Create a docker-compose.yml file with the following content: 24 | 25 | ```yaml title="docker-compose.yaml" 26 | name: editor-for-esphome 27 | services: 28 | editor-for-esphome: 29 | image: morcatko/esphome-editor:latest 30 | container_name: editor-for-esphome 31 | environment: 32 | - ESPHOME_URL=http://192.168.0.99:6052 # Replace with your ESPHome dashboard URL 33 | ports: 34 | - 8080:3000 # Change 8080 to any external port you prefer 35 | volumes: 36 | - /home/esphome-editor/devices:/app/work-folder/devices 37 | ``` 38 | 39 | Start the container: ```docker compose up -d``` 40 | Once running, open your browser and go to http://localhost:8080 (or the external port you configured) to access the editor. -------------------------------------------------------------------------------- /docs/docs/04-code-samples/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Code Samples", 3 | "position": 4, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Just some random configuration samples." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/04-code-samples/multiple-components.md: -------------------------------------------------------------------------------- 1 | # Multiple Components 2 | 3 | PLC-like device with many identical inputs/outputs. 4 | 5 | ```yaml title="PLC/.lib/plc.eta" 6 | <%- 7 | const toHex = (number, digits) => number?.toString(16)?.padStart(digits, '0'); 8 | const inputStartAddress = 0x00C0; 9 | const outputStartAddress = 0x0070; 10 | %> 11 | uart: 12 | tx_pin: 43 13 | rx_pin: 44 14 | baud_rate: 115200 15 | modbus: 16 | - id: mb_main 17 | flow_control_pin: 2 18 | modbus_controller: 19 | - id: mbc_1 20 | address: 0x1 21 | modbus_id: mb_main 22 | update_interval: 50ms 23 | 24 | binary_sensor: 25 | <%- for (let i = 0; i < it.inputs; i++) { 26 | const offset = Math.trunc(i/16); 27 | const bit = i%16; 28 | %> 29 | - platform: modbus_controller 30 | modbus_controller_id: mbc_1 31 | id: input_0x<%= toHex(i+1, 2) %> 32 | register_type: holding 33 | address: 0x<%= toHex(inputStartAddress + offset, 4) %> 34 | bitmask: 0x<%= toHex(1 << bit, 4) %> # bit (<%= bit %>) 35 | <%- } %> 36 | 37 | switch: 38 | <%- for (let i = 0; i < it.outputs; i++) { 39 | const offset = Math.trunc(i/16); 40 | const bit = i%16; 41 | %> 42 | - platform: modbus_controller 43 | modbus_controller_id: mbc_1 44 | id: output_0x<%= toHex(i+1, 2) %> 45 | register_type: holding 46 | address: 0x<%= toHex(outputStartAddress + offset, 4) %> 47 | bitmask: 0x<%= toHex(1 << bit, 4) %> # bit (<%= bit %>) 48 | <%_ } _%> 49 | ``` 50 | 51 | ```yaml title="PLC/plc.eta" 52 | <%~ include('./.lib/plc', { 53 | outputs: 32, 54 | inputs: 32 55 | }) %> 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/docs/04-code-samples/multiple-devices.md: -------------------------------------------------------------------------------- 1 | # Multiple Devices 2 | 3 | Multiple devices with identical configurations, differing only by name (e.g., thermometers spread across your home). 4 | 5 | 1. Create an `.eta` template shared by all devices. 6 | 7 | ```yaml title=".lib/my-device.eta" 8 | esphome: 9 | name: <%= it.name %> 10 | 11 | sensor: 12 | - platform: dht 13 | pin: D2 14 | temperature: 15 | name: "Temperature" 16 | humidity: 17 | name: "Humidity" 18 | update_interval: <%= it.update_interval %> 19 | ``` 20 | 21 | 2. Create a file for each device: 22 | 23 | ```yaml title="Living Room/index.eta" 24 | <%~ include('../.lib/my-device', 25 | { 26 | name: 'Living Room', 27 | update_interval: '60s' 28 | }) %> 29 | ``` 30 | ```yaml title="Kitchen/index.eta" 31 | <%~ include('../.lib/my-device', 32 | { 33 | name: 'Kitchen', 34 | update_interval: '30s' 35 | }) %> 36 | ``` 37 | ```yaml title="Bathroom/index.eta" 38 | <%~ include('../.lib/my-device', 39 | { 40 | name: 'Bathroom', 41 | update_interval: '30s' 42 | }) %> 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import {themes as prismThemes} from 'prism-react-renderer'; 2 | import type {Config} from '@docusaurus/types'; 3 | import type * as Preset from '@docusaurus/preset-classic'; 4 | 5 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) 6 | 7 | const config: Config = { 8 | title: 'Editor for ESPHome docs', 9 | tagline: "If you're working with devices that have many similar components or deploying multiple similar devices, this tool is here to save you time and effort", 10 | favicon: 'img/favicon.ico', 11 | 12 | // Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future 13 | future: { 14 | v4: true, // Improve compatibility with the upcoming Docusaurus v4 15 | experimental_faster: true, 16 | }, 17 | 18 | url: 'https://editor-4-esphome.github.io', 19 | baseUrl: '/', 20 | 21 | githubHost: 'github-private', 22 | organizationName: 'editor-4-esphome', 23 | projectName: 'editor-4-esphome.github.io', 24 | deploymentBranch: 'main', 25 | trailingSlash: true, 26 | 27 | onBrokenLinks: 'throw', 28 | onBrokenMarkdownLinks: 'warn', 29 | 30 | markdown: { 31 | mermaid: true, // Enable Mermaid diagrams in markdown 32 | }, 33 | themes: ['@docusaurus/theme-mermaid'], 34 | 35 | i18n: { 36 | defaultLocale: 'en', 37 | locales: ['en'], 38 | }, 39 | 40 | presets: [ 41 | [ 42 | 'classic', 43 | { 44 | docs: { 45 | sidebarPath: './sidebars.ts', 46 | routeBasePath: '/', 47 | editUrl: 'https://github.com/Morcatko/EspHome-Editor/tree/main/docs/', 48 | }, 49 | blog: false, 50 | theme: { 51 | customCss: './src/css/custom.css', 52 | }, 53 | } satisfies Preset.Options, 54 | ], 55 | ], 56 | 57 | themeConfig: { 58 | colorMode: { 59 | defaultMode: 'light', 60 | disableSwitch: true, 61 | respectPrefersColorScheme: true, // Respect user's color scheme preference 62 | }, 63 | navbar: { 64 | title: 'Editor for ESPHome', 65 | logo: { 66 | alt: '.', 67 | src: 'img/logo.svg', 68 | }, 69 | items: [ 70 | { 71 | type: 'docSidebar', 72 | sidebarId: 'docsSidebar', 73 | position: 'left', 74 | label: 'Docs', 75 | }, 76 | { 77 | href: 'https://github.com/Morcatko/EspHome-Editor', 78 | label: 'GitHub', 79 | position: 'right', 80 | }, 81 | ], 82 | }, 83 | footer: { 84 | style: 'dark', 85 | links: [ 86 | { 87 | items: [ 88 | { 89 | label: 'Changelog', 90 | href: 'https://github.com/Morcatko/EspHome-Editor/releases', 91 | } 92 | ], 93 | }, 94 | { 95 | items: [ 96 | { 97 | label: 'Docs GitHub', 98 | href: 'https://github.com/editor-4-esphome/docs', 99 | }, 100 | ], 101 | }, 102 | { 103 | items: [ 104 | { 105 | label: 'HA Addon', 106 | href: 'https://github.com/Morcatko/ha-addons', 107 | } 108 | ] 109 | }, 110 | ], 111 | copyright: `Copyright © ${new Date().getFullYear()}. Built with Docusaurus.`, 112 | }, 113 | prism: { 114 | theme: prismThemes.github, 115 | darkTheme: prismThemes.dracula, 116 | }, 117 | } satisfies Preset.ThemeConfig, 118 | }; 119 | 120 | export default config; 121 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "x", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.8.0", 19 | "@docusaurus/preset-classic": "3.8.0", 20 | "@docusaurus/theme-mermaid": "^3.8.0", 21 | "@mdx-js/react": "^3.0.0", 22 | "clsx": "^2.0.0", 23 | "prism-react-renderer": "^2.3.0", 24 | "react": "^19.0.0", 25 | "react-dom": "^19.0.0" 26 | }, 27 | "devDependencies": { 28 | "@docusaurus/faster": "^3.8.0", 29 | "@docusaurus/module-type-aliases": "3.8.0", 30 | "@docusaurus/tsconfig": "3.8.0", 31 | "@docusaurus/types": "3.8.0", 32 | "typescript": "~5.6.2" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.5%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 3 chrome version", 42 | "last 3 firefox version", 43 | "last 5 safari version" 44 | ] 45 | }, 46 | "engines": { 47 | "node": ">=18.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | Visit [https://editor-4-esphome.github.io/](https://editor-4-esphome.github.io/) 2 | 3 | [Development](./development.md) -------------------------------------------------------------------------------- /docs/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; 2 | 3 | // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) 4 | 5 | const sidebars: SidebarsConfig = { 6 | docsSidebar: [{type: 'autogenerated', dirName: '.'}], 7 | }; 8 | 9 | export default sidebars; 10 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #2e8555; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme='dark'] { 22 | --ifm-color-primary: #25c2a0; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morcatko/EspHome-Editor/e39ff003c71ea6d8d7a21670eb7b20d3d95f7800/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morcatko/EspHome-Editor/e39ff003c71ea6d8d7a21670eb7b20d3d95f7800/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/static/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morcatko/EspHome-Editor/e39ff003c71ea6d8d7a21670eb7b20d3d95f7800/docs/static/img/screenshot.png -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | }, 7 | "exclude": [".docusaurus", "build"] 8 | } 9 | -------------------------------------------------------------------------------- /examples/.lib/header.eta: -------------------------------------------------------------------------------- 1 | esphome: 2 | name: "<%= it.name %>" 3 | 4 | esp32: 5 | board: <%= it.board %> 6 | 7 | logger: 8 | api: 9 | ota: 10 | - platform: esphome 11 | 12 | web_server: 13 | port: 80 14 | 15 | wifi: 16 | ssid: !secret wifi_ssid 17 | password: !secret wifi_password 18 | fast_connect: true -------------------------------------------------------------------------------- /examples/.lib/my-device.eta: -------------------------------------------------------------------------------- 1 | <%~ include('header', 2 | { 3 | name: it.name, 4 | board: 'esp32-s3-devkitc-1' 5 | }) %> 6 | 7 | sensor: 8 | - platform: dht 9 | pin: D2 10 | temperature: 11 | name: "Temperature" 12 | humidity: 13 | name: "Humidity" 14 | update_interval: <%= it.update_interval %> -------------------------------------------------------------------------------- /examples/Bathroom/index.eta: -------------------------------------------------------------------------------- 1 | <%~ include('../.lib/my-device', 2 | { 3 | name: 'Bathroom', 4 | update_interval: '60s' 5 | }) %> -------------------------------------------------------------------------------- /examples/Kitchen/index.eta: -------------------------------------------------------------------------------- 1 | <%~ include('../.lib/my-device', 2 | { 3 | name: 'Kitchen', 4 | update_interval: '60s' 5 | }) %> -------------------------------------------------------------------------------- /examples/Living Room/index.eta: -------------------------------------------------------------------------------- 1 | <%~ include('../.lib/my-device', 2 | { 3 | name: 'Living Room', 4 | update_interval: '60s' 5 | }) %> -------------------------------------------------------------------------------- /examples/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | name: editor-for-esphome 2 | services: 3 | editor-for-esphome: 4 | image: morcatko/esphome-editor:latest 5 | container_name: editor-for-esphome 6 | environment: 7 | - ESPHOME_URL= #fill your esphome url 8 | ports: 9 | - 8080:3000 10 | volumes: 11 | - ./:/app/work-folder/devices -------------------------------------------------------------------------------- /examples/plc/.lib/plc.eta: -------------------------------------------------------------------------------- 1 | <%- 2 | const toHex = (number, digits) => number?.toString(16)?.padStart(digits, '0'); 3 | const inputStartAddress = 0x00C0; 4 | const outputStartAddress = 0x0070; 5 | %> 6 | 7 | modbus: 8 | - id: mb_main 9 | flow_control_pin: 2 10 | 11 | modbus_controller: 12 | - id: mbc_1 13 | address: 0x1 14 | modbus_id: mb_main 15 | update_interval: 50ms 16 | 17 | binary_sensor: 18 | <%- for (let i = 0; i < it.inputs; i++) { 19 | const offset = Math.trunc(i/16); 20 | const bit = i%16; 21 | %> 22 | - platform: modbus_controller 23 | modbus_controller_id: mbc_1 24 | id: input_0x<%= toHex(i+1, 2) %> 25 | register_type: holding 26 | address: 0x<%= toHex(inputStartAddress + offset, 4) %> 27 | bitmask: 0x<%= toHex(1 << bit, 4) %> # bit (<%= bit %>) 28 | <%- } %> 29 | 30 | switch: 31 | <%- for (let i = 0; i < it.outputs; i++) { 32 | const offset = Math.trunc(i/16); 33 | const bit = i%16; 34 | %> 35 | - platform: modbus_controller 36 | modbus_controller_id: mbc_1 37 | id: output_0x<%= toHex(i+1, 2) %> 38 | register_type: holding 39 | address: 0x<%= toHex(outputStartAddress + offset, 4) %> 40 | bitmask: 0x<%= toHex(1 << bit, 4) %> # bit (<%= bit %>) 41 | <%_ } _%> 42 | -------------------------------------------------------------------------------- /examples/plc/plc.eta: -------------------------------------------------------------------------------- 1 | <%~ include('../.lib/header', 2 | { 3 | name: ' PLC', 4 | board: 'esp32-s3-devkitc-1' 5 | }) %> 6 | 7 | <%~ include('./.lib/plc', 8 | { 9 | inputs: 32, 10 | outputs: 32 11 | }) %> -------------------------------------------------------------------------------- /examples/plc/uart.yaml: -------------------------------------------------------------------------------- 1 | uart: 2 | tx_pin: 43 3 | rx_pin: 44 4 | baud_rate: 115200 5 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin' 3 | 4 | const nextConfig: NextConfig = { 5 | output: "standalone", 6 | /* config options here */ 7 | eslint: { 8 | // Warning: This allows production builds to successfully complete even if 9 | // your project has ESLint errors. 10 | ignoreDuringBuilds: true, 11 | }, 12 | assetPrefix: ".", 13 | typescript: { 14 | ignoreBuildErrors: false, 15 | }, 16 | //https://github.com/Chenalejandro/next.js/blob/add-monaco-editor-example/examples/monaco-editor/next.config.mjs 17 | //https://github.com/microsoft/monaco-editor/blob/main/docs/integrate-esm.md 18 | webpack: (config, options) => { 19 | if (!options.isServer) { 20 | config.plugins.push( 21 | new MonacoWebpackPlugin({ 22 | // you can add other languages here as needed 23 | // (list of languages: https://github.com/microsoft/monaco-editor/tree/main/src/basic-languages) 24 | //languages: ['javascript', 'typescript', 'php', 'python'], 25 | filename: 'static/[name].worker.[contenthash].js', 26 | }) 27 | ) 28 | } 29 | return config 30 | }, 31 | }; 32 | 33 | export default nextConfig; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "editor-for-esphome", 3 | "version": "0.19.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "tsc": "tsc --noEmit --incremental", 11 | "test": "vitest", 12 | "postinstall": "next-ws patch --yes", 13 | "setup": "npx tsx ./setup.mts", 14 | "tasks": "npx tsx --env-file=.env.local ./tasks.mts" 15 | }, 16 | "dependencies": { 17 | "@mantine/core": "^8.0.1", 18 | "@mantine/hooks": "^8.0.1", 19 | "@mantine/modals": "^8.0.1", 20 | "@mantine/notifications": "^8.0.1", 21 | "@monaco-editor/react": "^4.6.0", 22 | "@primer/octicons-react": "^19.15.0", 23 | "@tanstack/react-query": "^5.62.16", 24 | "@tanstack/react-virtual": "^3.11.2", 25 | "@types/ws": "^8.5.13", 26 | "ansi-to-html": "^0.7.2", 27 | "consola": "^3.4.0", 28 | "dockview-react": "^4.2.1", 29 | "eta": "^3.5.0", 30 | "jotai": "^2.11.3", 31 | "jsonpathly": "^2.0.2", 32 | "marked": "^15.0.7", 33 | "monaco-editor": "^0.52.2", 34 | "nanoevents": "^9.1.0", 35 | "next": "^15.1.6", 36 | "next-ws": "^2.0.1", 37 | "nuqs": "^2.3.2", 38 | "react": "^19.0.0", 39 | "react-dom": "^19.0.0", 40 | "usehooks-ts": "^3.1.1", 41 | "ws": "^8.18.0", 42 | "yaml": "^2.7.0" 43 | }, 44 | "devDependencies": { 45 | "@inquirer/prompts": "^7.2.3", 46 | "@tailwindcss/postcss": "^4.0.0", 47 | "@types/node": "^22.10.5", 48 | "@types/react": "^19.0.3", 49 | "@types/react-dom": "^19.0.2", 50 | "eslint": "^9.19.0", 51 | "eslint-config-next": "^15.1.6", 52 | "execa": "^9.5.2", 53 | "monaco-editor-webpack-plugin": "^7.1.0", 54 | "postcss": "^8.5.1", 55 | "postcss-preset-mantine": "^1.17.0", 56 | "postcss-simple-vars": "^7.0.1", 57 | "tailwindcss": "^4.0.0", 58 | "typescript": "^5", 59 | "vitest": "^3.0.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | '@tailwindcss/postcss': {}, 5 | 'postcss-preset-mantine': {}, 6 | 'postcss-simple-vars': { 7 | variables: { 8 | 'mantine-breakpoint-xs': '36em', 9 | 'mantine-breakpoint-sm': '48em', 10 | 'mantine-breakpoint-md': '62em', 11 | 'mantine-breakpoint-lg': '75em', 12 | 'mantine-breakpoint-xl': '88em', 13 | }, 14 | }, 15 | }, 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morcatko/EspHome-Editor/e39ff003c71ea6d8d7a21670eb7b20d3d95f7800/public/favicon.ico -------------------------------------------------------------------------------- /setup.mts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | 3 | const prepareDir = async (dir: string) => { 4 | try { 5 | await fs.rm(dir, { recursive: true, force: true }); 6 | await fs.mkdir(dir, { recursive: true }); 7 | } catch (error) { 8 | console.error(`Error preparing directory: ${dir} ${error}`); 9 | } 10 | } 11 | 12 | const downloadFile = async (url: string, destinationFile: string) => { 13 | const content = await (await fetch(url)).text(); 14 | await fs.writeFile(destinationFile, content); 15 | } 16 | 17 | const modifyFile = async (file: string, callback: (content: string) => string) => { 18 | const content = (await fs.readFile(file)).toString(); 19 | const newContent = callback(content); 20 | await fs.writeFile(file, newContent); 21 | } 22 | 23 | const downloadEspHomeSchemas = async () => { 24 | const fileList: any[] = await (await fetch("https://api.github.com/repos/esphome/esphome-schema/contents/schema?ref=release")).json(); 25 | 26 | const targetRoot = "./public/esphome_schemas"; 27 | await prepareDir(targetRoot); 28 | 29 | const promises = fileList.map((file) => downloadFile(file.download_url, `${targetRoot}/${file.name}`)); 30 | 31 | await Promise.all(promises) 32 | console.log(`Downloaded ${promises.length} EspHome schema files`); 33 | } 34 | 35 | const downloadEspHomeMonacoFiles = async () => { 36 | const downloadSrcEditor = async (fileName: string) => 37 | downloadFile(`https://raw.githubusercontent.com/esphome/dashboard/main/src/editor/${fileName}`, `${targetRoot}/${fileName}`); 38 | 39 | const targetRoot = "./src/3rd-party/esphome-dashboard/src/editor"; 40 | await prepareDir(`${targetRoot}`); 41 | await prepareDir(`${targetRoot}/utils`); 42 | 43 | const promises = [ 44 | "completions-handler.ts", 45 | "definition-handler.ts", 46 | "editor-shims.ts", 47 | "esphome-document.ts", 48 | "esphome-schema.ts", 49 | "hover-handler.ts", 50 | "utils/objects.ts", 51 | "utils/text-buffer.ts", 52 | ] 53 | .map((file) => downloadSrcEditor(file)); 54 | 55 | await Promise.all(promises) 56 | console.log(`Downloaded ${promises.length} EspHome Monaco-Editor files`); 57 | 58 | await modifyFile(`${targetRoot}/editor-shims.ts`, (content) => 59 | content.replace("static/schema/${name}.json", "./esphome_schemas/${name}.json") 60 | ); 61 | } 62 | 63 | await downloadEspHomeSchemas(); 64 | await downloadEspHomeMonacoFiles(); 65 | -------------------------------------------------------------------------------- /src/app/api/api-types.ts: -------------------------------------------------------------------------------- 1 | export type TParams = { 2 | params: Promise; 3 | }; 4 | 5 | export type TDeviceId = { 6 | device_id: string; 7 | } 8 | 9 | export type TDeviceIdAndPath = TDeviceId & { 10 | path: string; 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/esphome/compile/route.ts: -------------------------------------------------------------------------------- 1 | import * as ws from "ws"; 2 | import * as http from "node:http"; 3 | import { streamToWs } from "../utils"; 4 | import { TDeviceId, TParams } from "@/app/api/api-types"; 5 | 6 | export function GET() {} 7 | 8 | export async function SOCKET( 9 | client: ws.WebSocket, 10 | request: http.IncomingMessage, 11 | server: ws.WebSocketServer, 12 | params: TParams 13 | ) { 14 | await streamToWs(params, client, "compile"); 15 | } -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/esphome/install/route.ts: -------------------------------------------------------------------------------- 1 | import * as ws from "ws"; 2 | import * as http from "node:http"; 3 | import { streamToWs } from "../utils"; 4 | import { TDeviceId, TParams } from "@/app/api/api-types"; 5 | 6 | export function GET() {} 7 | 8 | export async function SOCKET( 9 | client: ws.WebSocket, 10 | request: http.IncomingMessage, 11 | server: ws.WebSocketServer, 12 | params: TParams 13 | ) { 14 | await streamToWs(params, client, "run", { port: "OTA" }); 15 | } -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/esphome/log/route.ts: -------------------------------------------------------------------------------- 1 | import * as ws from "ws"; 2 | import * as http from "node:http"; 3 | import { streamToWs } from "../utils"; 4 | import { TDeviceId, TParams } from "@/app/api/api-types"; 5 | 6 | export function GET() {} 7 | 8 | export async function SOCKET( 9 | client: ws.WebSocket, 10 | request: http.IncomingMessage, 11 | server: ws.WebSocketServer, 12 | params: TParams 13 | ) { 14 | await streamToWs(params, client, "logs", { port: "OTA" }); 15 | } -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/esphome/route.ts: -------------------------------------------------------------------------------- 1 | import type { TDeviceId, TParams } from "@/app/api/api-types"; 2 | import { espHome } from "@/server/devices/esphome"; 3 | import { local } from "@/server/devices/local"; 4 | 5 | export async function GET( 6 | request: Request, 7 | { params }: TParams, 8 | ) { 9 | const { device_id } = await params; 10 | const content = await espHome.getConfiguration(device_id); 11 | 12 | return new Response( 13 | content, 14 | { 15 | headers: { 16 | "content-type": "text/plain", 17 | }, 18 | }, 19 | ); 20 | } 21 | 22 | export async function POST( 23 | request: Request, 24 | { params }: TParams, 25 | ) { 26 | const { device_id } = await params; 27 | 28 | const content = await local.compileDevice(device_id); 29 | if (!content.success) { 30 | return new Response( 31 | "Failed to compile device", 32 | { 33 | status: 500, 34 | headers: { 35 | "content-type": "text/plain", 36 | }, 37 | }, 38 | ); 39 | } 40 | 41 | await espHome.saveConfiguration(device_id, content.value); 42 | 43 | return new Response( 44 | "OK", 45 | { 46 | headers: { 47 | "content-type": "text/plain", 48 | }, 49 | }, 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/esphome/utils.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket } from "ws"; 2 | import { espHome } from "@/server/devices/esphome"; 3 | import { TDeviceId, TParams } from "@/app/api/api-types"; 4 | import { log } from "@/shared/log"; 5 | 6 | export type TWsMessage = { 7 | event: "message" | "completed" | "error"; 8 | data: string; 9 | } 10 | 11 | export async function streamToWs( 12 | { params }: TParams, 13 | client: WebSocket, 14 | path: string, 15 | spawnParams: Record | null = null 16 | ) { 17 | const { device_id } = await params; 18 | 19 | const send = (event: string, data: string) => { 20 | client.send(JSON.stringify({ event, data: data.trim() })); 21 | } 22 | 23 | return new Promise(async (res, rej) => { 24 | const socket = await espHome 25 | .stream( 26 | device_id!, 27 | path, 28 | spawnParams, 29 | (e) => send("message", e.data), 30 | (c) => res(c), 31 | (e) => rej(e) 32 | ); 33 | client.on("close", () => { 34 | log.debug("Client closed connection"); 35 | socket.close(); 36 | }); 37 | }) 38 | .then(() => { send("completed", ""); client.close(); }) 39 | .catch((e) => { send("error", e.toString()); client.close(); }); 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/local/[path]/compiled/route.ts: -------------------------------------------------------------------------------- 1 | import type { TDeviceIdAndPath, TParams } from "@/app/api/api-types"; 2 | import { local } from "@/server/devices/local"; 3 | 4 | export async function GET( 5 | request: Request, 6 | { params }: TParams) { 7 | const { device_id, path } = await params; 8 | 9 | let result: string; 10 | let status = 200; 11 | try { 12 | result = await local.compileFile(device_id, path, true); 13 | } catch (e) { 14 | const error = e as Error; 15 | result = error.message + "\n" + error.stack; 16 | status = 400; 17 | } 18 | 19 | return new Response( 20 | result, 21 | { 22 | status: status, 23 | headers: { 24 | "content-type": "text/plain", 25 | }, 26 | } 27 | ); 28 | } -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/local/[path]/rename_to/[new_name]/route.ts: -------------------------------------------------------------------------------- 1 | import type { TDeviceIdAndPath, TParams } from "@/app/api/api-types"; 2 | import { local } from "@/server/devices/local"; 3 | 4 | type TPath = TDeviceIdAndPath & { 5 | new_name: string; 6 | } 7 | 8 | export async function POST(request: Request, { params }: TParams) { 9 | const { device_id, path, new_name } = await params; 10 | 11 | await local.renameFile(device_id, path, new_name); 12 | 13 | return new Response( 14 | "OK", 15 | { 16 | headers: { 17 | "content-type": "text/plain", 18 | }, 19 | }, 20 | ); 21 | } -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/local/[path]/route.ts: -------------------------------------------------------------------------------- 1 | import type { TDeviceIdAndPath, TParams } from "@/app/api/api-types"; 2 | import { local } from "@/server/devices/local"; 3 | 4 | export async function GET(request: Request, { params }: TParams) { 5 | const { device_id, path } = await params; 6 | 7 | const content = await local.getFileContent(device_id, path); 8 | 9 | return new Response( 10 | content, 11 | { 12 | headers: { 13 | "content-type": "text/plain", 14 | }, 15 | }, 16 | ); 17 | } 18 | 19 | export async function PUT(request: Request, { params }: TParams) { 20 | const { device_id, path } = await params; 21 | const body = await request.text(); 22 | switch (body) { 23 | case "directory": 24 | await local.createDirectory(device_id, path); 25 | break; 26 | case "file": 27 | await local.createFile(device_id, path); 28 | break; 29 | default: 30 | throw new Error(`Invalid body: ${body}`); 31 | } 32 | 33 | return new Response( 34 | "OK", 35 | { 36 | headers: { 37 | "content-type": "text/plain", 38 | }, 39 | }, 40 | ); 41 | } 42 | 43 | export async function POST(request: Request, { params }: TParams) { 44 | const { device_id, path } = await params; 45 | 46 | const content = await request.text(); 47 | 48 | await local.saveFileContent(device_id, path, content); 49 | 50 | return new Response( 51 | "OK", 52 | { 53 | headers: { 54 | "content-type": "text/plain", 55 | }, 56 | }, 57 | ); 58 | } 59 | 60 | 61 | export async function DELETE(request: Request, { params }: TParams) { 62 | const { device_id, path } = await params; 63 | await local.deletePath(device_id, path); 64 | 65 | return new Response( 66 | "OK", 67 | { 68 | headers: { 69 | "content-type": "text/plain", 70 | }, 71 | }, 72 | ); 73 | } -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/local/[path]/test-data/route.ts: -------------------------------------------------------------------------------- 1 | import type { TDeviceIdAndPath, TParams } from "@/app/api/api-types"; 2 | import { local } from "@/server/devices/local"; 3 | 4 | export async function GET( 5 | request: Request, 6 | { params }: TParams) { 7 | const { device_id, path } = await params; 8 | 9 | const content = await local.tryGetFileContent(device_id, path + ".testdata") ?? "{\n}"; 10 | 11 | return new Response( 12 | content, 13 | { 14 | headers: { 15 | "content-type": "text/plain", 16 | }, 17 | }, 18 | ); 19 | } 20 | 21 | export async function POST( 22 | request: Request, 23 | { params }: TParams) { 24 | const { device_id, path } = await params; 25 | 26 | const content = await request.text(); 27 | 28 | await local.saveFileContent(device_id, path + ".testdata", content); 29 | 30 | return new Response( 31 | "OK", 32 | { 33 | headers: { 34 | "content-type": "text/plain", 35 | }, 36 | }, 37 | ); 38 | } -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/local/[path]/toggle_enabled/route.ts: -------------------------------------------------------------------------------- 1 | import type { TDeviceIdAndPath, TParams } from "@/app/api/api-types"; 2 | import { local } from "@/server/devices/local"; 3 | 4 | export async function POST(request: Request, { params }: TParams) { 5 | const { device_id, path } = await params; 6 | 7 | await local.togglePathEnabled(device_id, path); 8 | 9 | return new Response( 10 | "OK", 11 | { 12 | headers: { 13 | "content-type": "text/plain", 14 | }, 15 | }, 16 | ); 17 | } -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/local/route.ts: -------------------------------------------------------------------------------- 1 | import type { TDeviceId, TParams, } from "@/app/api/api-types"; 2 | import { importEspHomeToLocalDevice } from "@/server/devices"; 3 | import { local } from "@/server/devices/local"; 4 | import { NextRequest } from "next/server"; 5 | 6 | export async function GET( 7 | request: NextRequest, 8 | { params }: TParams, 9 | ) { 10 | const { device_id } = await params; 11 | try { 12 | return new Response( 13 | JSON.stringify(await local.compileDevice(device_id)), 14 | { 15 | status: 200, 16 | headers: { 17 | "content-type": "application/json", 18 | }, 19 | }, 20 | ); 21 | } catch (e: any) { 22 | return new Response( 23 | JSON.stringify({ error: e.message }), 24 | { 25 | status: 500, 26 | headers: { 27 | "content-type": "text/plain", 28 | }, 29 | }, 30 | ); 31 | } 32 | } 33 | 34 | export async function PUT(request: Request, { params }: TParams) { 35 | const { device_id } = await params; 36 | await local.saveFileContent(device_id, "configuration.yaml", ""); 37 | 38 | return new Response( 39 | "OK", 40 | { 41 | headers: { 42 | "content-type": "text/plain", 43 | }, 44 | }, 45 | ); 46 | } 47 | 48 | export async function POST(request: Request, { params }: TParams) { 49 | const { device_id } = await params; 50 | await importEspHomeToLocalDevice(device_id); 51 | 52 | return new Response( 53 | "OK", 54 | { 55 | headers: { 56 | "content-type": "text/plain", 57 | }, 58 | }, 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/app/api/device/[device_id]/route.ts: -------------------------------------------------------------------------------- 1 | import { TDeviceId, TParams } from "../../api-types"; 2 | import { deleteDevice } from "@/server/devices"; 3 | 4 | export async function DELETE(request: Request, { params }: TParams) { 5 | const { device_id } = await params; 6 | await deleteDevice(device_id); 7 | 8 | return new Response( 9 | "OK", 10 | { 11 | headers: { 12 | "content-type": "text/plain", 13 | }, 14 | }, 15 | ); 16 | } -------------------------------------------------------------------------------- /src/app/api/device/ping/route.ts: -------------------------------------------------------------------------------- 1 | import { espHome } from "@/server/devices/esphome"; 2 | 3 | export async function GET() { 4 | return Response.json(await espHome.getPing()); 5 | } -------------------------------------------------------------------------------- /src/app/api/device/route.ts: -------------------------------------------------------------------------------- 1 | import { getTreeData } from "@/server/devices"; 2 | 3 | export async function GET() { 4 | const result = await getTreeData(); 5 | 6 | return Response.json(result); 7 | } -------------------------------------------------------------------------------- /src/app/api/status/route.ts: -------------------------------------------------------------------------------- 1 | import { c } from "@/server/config"; 2 | 3 | const getStatus = () => ({ 4 | version: c.version, 5 | mode: c.mode, 6 | espHomeWebUrl: c.espHomeWebUrl, 7 | }); 8 | 9 | export type TGetStatus = ReturnType; 10 | export async function GET() { 11 | return Response.json(getStatus()); 12 | } -------------------------------------------------------------------------------- /src/app/client-layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { queryClient } from "./stores"; 3 | import { QueryClientProvider } from "@tanstack/react-query"; 4 | 5 | export function ClientLayout({ 6 | children, 7 | }: Readonly<{ 8 | children: React.ReactNode; 9 | }>) { 10 | return 11 | {children} 12 | ; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/components/devices-tree/device-toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { useDevicesStore } from "@/app/stores/devices-store"; 2 | import { PanelTarget, usePanelsStore } from "@/app/stores/panels-store"; 3 | import { color_esphome, color_gray, color_local } from "@/app/utils/const"; 4 | import { useDarkTheme } from "@/app/utils/hooks"; 5 | import { TDevice } from "@/server/devices/types"; 6 | import { ActionIcon } from "@mantine/core"; 7 | import { BeakerIcon, CodeIcon, DownloadIcon, GitCompareIcon, LogIcon, UploadIcon } from "@primer/octicons-react"; 8 | import { ToolbarItem, TToolbarButtonProps } from "../toolbar"; 9 | import { TPanel_DeviceOperation } from "@/app/stores/panels-store/types"; 10 | 11 | type TDeviceToolbarItemProps = 12 | Pick & { 13 | device: TDevice; 14 | panelTarget?: PanelTarget; 15 | icon?: TToolbarButtonProps["icon"]; 16 | tooltip?: TToolbarButtonProps["tooltip"]; 17 | }; 18 | 19 | type TDeviceToolbarButtonProps_Base = 20 | Pick & 21 | Pick & 22 | Pick & 23 | Pick & 24 | Pick; 25 | 26 | type TDeviceToolbarButtonProps_Panel = TDeviceToolbarButtonProps_Base & { 27 | device: TDevice; 28 | operation: TPanel_DeviceOperation["operation"]; 29 | panelTarget?: PanelTarget; 30 | }; 31 | 32 | const DTB_Panel = (p: TDeviceToolbarButtonProps_Panel) => { 33 | const panelsStore = usePanelsStore(); 34 | return 36 | panelsStore.addDevicePanel( 37 | ((e.button === 1) ? "new_window" : p.panelTarget) ?? "default", 38 | p.device.id, 39 | p.operation)} 40 | />; 41 | }; 42 | 43 | 44 | type TDeviceToolbarButtonProps_Device = TDeviceToolbarButtonProps_Base & { 45 | onClick: (ds: ReturnType) => void; 46 | }; 47 | 48 | const DTB_Device = (p: TDeviceToolbarButtonProps_Device) => { 49 | const devicesStore = useDevicesStore(); 50 | return p.onClick(devicesStore)} />; 51 | } 52 | 53 | 54 | export const DeviceToolbarItem = { 55 | LocalShow: (p: TDeviceToolbarItemProps) => } operation="local_device" color={color_local} {...p} />, 56 | LocalImport: (p: TDeviceToolbarItemProps) => } onClick={(ds) => ds.localDevice_import(p.device.id)} color={color_local} {...p} />, 57 | Diff: (p: TDeviceToolbarItemProps) => { 58 | const isDarkMode = useDarkTheme(); 59 | const hasBoth = !!p.device.files && !!p.device.esphome_config; 60 | return } operation="diff" 61 | {...p} 62 | disabled={!hasBoth} 63 | color={(hasBoth) 64 | ? (isDarkMode ? "lightgrey" : color_gray) 65 | : (isDarkMode ? color_gray : "lightgrey")} />; 66 | }, 67 | ESPHomeUpload: (p: TDeviceToolbarItemProps) => { const d = useDarkTheme(); return } onClick={(ds) => ds.espHome_upload(p.device)} color={d ? "lightgrey" : color_gray} {...p} />; }, 68 | ESPHomeCreate: (p: TDeviceToolbarItemProps) => { const d = useDarkTheme(); return } onClick={(ds) => ds.espHome_upload(p.device)} color={d ? "lightgrey" : color_gray} {...p} />; }, 69 | ESPHomeShow: (p: TDeviceToolbarItemProps) => { const hasEspHomeConfig = !!p.device.esphome_config; return } operation="esphome_device" disabled={!hasEspHomeConfig} color={hasEspHomeConfig ? color_esphome : "lightgrey"}{...p} />; }, 70 | ESPHomeCompile: (p: TDeviceToolbarItemProps) => { const hasEspHomeConfig = !!p.device.esphome_config; return } operation="esphome_compile" disabled={!hasEspHomeConfig} color={hasEspHomeConfig ? color_esphome : "lightgrey"}{...p} />; }, 71 | ESPHomeInstall: (p: TDeviceToolbarItemProps) => { const hasEspHomeConfig = !!p.device.esphome_config; return } operation="esphome_install" disabled={!hasEspHomeConfig} color={hasEspHomeConfig ? color_esphome : "lightgrey"}{...p} />; }, 72 | ESPHomeLog: (p: TDeviceToolbarItemProps) => { const hasEspHomeConfig = !!p.device.esphome_config; return } operation="esphome_log" disabled={!hasEspHomeConfig} color={hasEspHomeConfig ? color_esphome : "lightgrey"} {...p} />; }, 73 | }; 74 | 75 | export const DeviceToolbar = ({ device }: { device: TDevice }) => { 76 | const hasLocalFiles = !!device.files; 77 | const hasESPHomeConfig = !!device.esphome_config; 78 | 79 | const uploadCreates = !hasESPHomeConfig; 80 | const props = { 81 | className: "opacity-80 hover:opacity-100", 82 | device: device 83 | } 84 | 85 | return
86 | 87 | {hasLocalFiles 88 | ? 89 | : 90 | } 91 | 92 | 93 | {uploadCreates 94 | ? 95 | : 96 | } 97 | 98 | 99 | 100 | 101 | 102 | 103 |
; 104 | }; -------------------------------------------------------------------------------- /src/app/components/devices-tree/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { useQuery } from "@tanstack/react-query"; 4 | import { FileDirectoryIcon, LightBulbIcon, PlusIcon, ChevronRightIcon } from "@primer/octicons-react"; 5 | import { Group, RenderTreeNodePayload, Tree, useTree } from "@mantine/core"; 6 | import { TDevice } from "@/server/devices/types"; 7 | import { color_gray, color_offline, color_online } from "../../utils/const"; 8 | import { api } from "../../utils/api-client"; 9 | import { useDevicesStore } from "../../stores/devices-store"; 10 | import { usePanelsStore } from "../../stores/panels-store"; 11 | import { DeviceToolbar } from "./device-toolbar"; 12 | import { ThreeDotsMenu, deviceMenuItems, fodMenuItems } from "./menus"; 13 | import { TreeNodeType, useTreeData } from "./utils"; 14 | import { FileIcon } from "@/app/utils/file-utils"; 15 | 16 | type TNodeProps = { 17 | nodePayload: RenderTreeNodePayload; 18 | children: React.ReactNode; 19 | onClick?: React.MouseEventHandler; 20 | icon?: React.ReactNode; 21 | menuItems?: React.ReactNode[]; 22 | hideExpander?: boolean; 23 | disabled?: boolean; 24 | } 25 | 26 | const Node = (p: TNodeProps) => { 27 | const { hasChildren, expanded, elementProps, tree, node } = p.nodePayload; 28 | elementProps.className += p.disabled ? " opacity-50" : ""; 29 | 30 | return tree.toggleExpanded(node.value)} 34 | > 35 | {!p.hideExpander && 38 | } 39 | {p.icon} 40 | {p.children} 41 | 42 | {p.menuItems && } 43 | ; 44 | } 45 | 46 | const nodeRenderer = (p: RenderTreeNodePayload) => { 47 | const node = p.node as TreeNodeType; 48 | const panels = usePanelsStore(); 49 | const devicesStore = useDevicesStore(); 50 | const pingQuery = useQuery({ 51 | queryKey: ['ping'], 52 | refetchInterval: 1000, 53 | queryFn: api.getPing, 54 | }); 55 | 56 | const getDeviceColor = (d: TDevice) => 57 | d.esphome_config 58 | ? pingQuery?.data?.[d.esphome_config] 59 | ? color_online 60 | : color_offline 61 | : color_gray; 62 | 63 | switch (node.type) { 64 | case "add_new_device": 65 | return } 67 | nodePayload={p} 68 | onClick={() => devicesStore.localDevice_create()}> 69 | New Device 70 | 71 | case "device": 72 | return } 75 | menuItems={deviceMenuItems(devicesStore, node.device)} > 76 | {node.label} 77 | 78 | case "device_toolbar": 79 | return 80 | 81 | ; 82 | case "root_lib": 83 | return } 86 | menuItems={deviceMenuItems(devicesStore, node.device)} > 87 | {node.label} 88 | 89 | case "directory": 90 | return } 93 | menuItems={fodMenuItems(devicesStore, node.device, node.fod)}> 94 | {node.label} 95 | ; 96 | case "file": 97 | return } 101 | onClick={(e) => panels.addDevicePanel(((e as any).button === 1) ? "new_window" : "default", node.device.id, "local_file", node.fod.path)} 102 | menuItems={fodMenuItems(devicesStore, node.device, node.fod)} 103 | > 104 | {node.label} 105 | ; 106 | case "directory_empty": 107 | return null; 108 | default: return Unsupported node 109 | } 110 | } 111 | 112 | export const DevicesTree = () => { 113 | const devicesStore = useDevicesStore(); 114 | 115 | const tree = useTree({ 116 | initialExpandedState: devicesStore.expanded.expanded, 117 | onNodeExpand: (node) => devicesStore.expanded.set(node, true), 118 | onNodeCollapse: (node) => devicesStore.expanded.set(node, false) 119 | }); 120 | const treeData = useTreeData(); 121 | 122 | return { } }} 125 | data={treeData} 126 | renderNode={nodeRenderer} 127 | className="text-sm" 128 | classNames={{ 129 | label: "dark:hover:bg-gray-800 hover:bg-gray-100 py-px", 130 | }} 131 | /> 132 | }; -------------------------------------------------------------------------------- /src/app/components/devices-tree/menus.tsx: -------------------------------------------------------------------------------- 1 | import { useDevicesStore } from "@/app/stores/devices-store"; 2 | import { TDevice, TLocalFileOrDirectory } from "@/server/devices/types"; 3 | import { ActionIcon, Menu } from "@mantine/core"; 4 | import { CircleSlashIcon, FileCodeIcon, FileDirectoryIcon, KebabHorizontalIcon, PencilIcon, XIcon } from "@primer/octicons-react"; 5 | 6 | export const ThreeDotsMenu = ({ items }: { items: React.ReactNode[] }) => 7 | 8 | 9 | e.stopPropagation()}> 14 | 15 | 16 | 17 | 18 | {items} 19 | 20 | ; 21 | 22 | 23 | 24 | const MenuItem = (p: { 25 | label: string; 26 | icon: React.ReactNode; 27 | onClick: () => void; 28 | }) => 29 | { e.stopPropagation(); p.onClick() }} > 32 | {p.label} 33 | ; 34 | 35 | export const deviceMenuItems = (ds: ReturnType, d: TDevice) => [ 36 | } onClick={() => ds.localDevice_addFile(d, "/")} />, 37 | } onClick={() => ds.localDevice_addDirectory(d, "/")} />, 38 | , 39 | } onClick={() => ds.device_delete(d)} />, 40 | ] 41 | 42 | export const fodMenuItems = (ds: ReturnType, d: TDevice, fod: TLocalFileOrDirectory) => [ 43 | ...(fod.type === "directory" 44 | ? [ 45 | } onClick={() => ds.localDevice_addFile(d, fod.path)} />, 46 | } onClick={() => ds.localDevice_addDirectory(d, fod.path)} />, 47 | ] 48 | : []), 49 | ...(fod.type === "file" 50 | ? [ 51 | } onClick={() => ds.local_toggleEnabled(d, fod)} />, 52 | ]: []), 53 | , 54 | } onClick={() => ds.local_renameFoD(d, fod)} />, 55 | } onClick={() => ds.local_deleteFoD(d, fod)} />, 56 | ]; -------------------------------------------------------------------------------- /src/app/components/devices-tree/utils.ts: -------------------------------------------------------------------------------- 1 | import { useDevicesStore } from "@/app/stores/devices-store"; 2 | import { TDevice, TLocalDirectory, TLocalFile, TLocalFileOrDirectory } from "@/server/devices/types"; 3 | import { TreeNodeData } from "@mantine/core"; 4 | import { useMemo } from "react"; 5 | 6 | export type TreeNodeType = TreeNodeData & ({ 7 | type: "add_new_device" 8 | } | { 9 | type: "root_lib" 10 | device: TDevice; 11 | } | { 12 | type: "device" 13 | device: TDevice; 14 | } | { 15 | type: "device_toolbar" 16 | device: TDevice; 17 | } | { 18 | type: "directory" 19 | device: TDevice; 20 | fod: TLocalDirectory 21 | } | { 22 | type: "directory_empty" 23 | } | { 24 | type: "file" 25 | device: TDevice; 26 | fod: TLocalFile; 27 | }); 28 | 29 | const mapFiles = (parentId: string, device: TDevice, files: TLocalFileOrDirectory[] | null): TreeNodeType[] => { 30 | if (!files || files.length === 0) 31 | return [{ 32 | type: "directory_empty", 33 | value: `${parentId}/__empty__`, 34 | label: "Empty", 35 | }]; 36 | 37 | return files.map((f) => { 38 | const id = `${parentId}/${f.id}`; 39 | const isDir = f.type === "directory"; 40 | return { 41 | type: isDir ? "directory" : "file", 42 | value: id, 43 | label: f.name, 44 | device: device, 45 | fod: isDir ? (f as TLocalDirectory) : (f as TLocalFile), 46 | children: isDir ? mapFiles(id, device, f.files) : [] 47 | }; 48 | }); 49 | } 50 | 51 | export const useTreeData = () => { 52 | const devicesStore = useDevicesStore(); 53 | const data = devicesStore.query.isSuccess ? devicesStore.query.data : [] 54 | return useMemo(() => [{ 55 | type: "add_new_device", 56 | value: "add_new_device", 57 | label: "New Device" 58 | }, 59 | ...data 60 | .map((d) => { 61 | const isLib = d.name == ".lib"; 62 | const children: TreeNodeType[] = isLib 63 | ? [] 64 | : [{ 65 | type: "device_toolbar", 66 | device: d, 67 | value: `${d.id}/__toolbar__`, 68 | label: "Toolbar" 69 | }] 70 | children.push(...mapFiles(d.id, d, d.files)); 71 | 72 | return { 73 | value: d.id, 74 | label: d.name, 75 | type: isLib ? "root_lib" : "device", 76 | device: d, 77 | children 78 | }; 79 | }) 80 | ], [data]); 81 | } -------------------------------------------------------------------------------- /src/app/components/dialogs/about-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { useStatusStore } from '@/app/stores/status-store'; 2 | import { LinkExternalIcon } from '@primer/octicons-react'; 3 | import { modals } from '@mantine/modals'; 4 | import { Anchor } from '@mantine/core'; 5 | 6 | const AboutDialogContent = () => { 7 | const statusStore = useStatusStore(); 8 | return
9 | {statusStore.query.isSuccess && <> 10 |
11 | Version: {statusStore.query.data?.version} 12 |
13 |
14 | ESPHome 15 |
16 | } 17 |
18 | Provide Feedback 19 |
20 |
; 21 | } 22 | export const openAboutDialog = () => 23 | modals.open({ 24 | title: Editor for ESPHome, 25 | children: 26 | }) -------------------------------------------------------------------------------- /src/app/components/dialogs/confirmation-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { modals } from "@mantine/modals"; 2 | 3 | type TContentProps = { 4 | subtitle: string; 5 | text: string; 6 | } 7 | 8 | const ConfirmationDialogContent = (props: TContentProps) => <> 9 |
{props.subtitle}
10 |
{props.text}
11 | ; 12 | 13 | type TDialogProps = { 14 | title: string; 15 | subtitle: string; 16 | text: string; 17 | danger: boolean 18 | } 19 | export const openConfirmationDialog = (props: TDialogProps) => 20 | new Promise((res) => 21 | modals.openConfirmModal({ 22 | title: props.title, 23 | children: , 24 | labels: { confirm: 'Confirm', cancel: 'Cancel' }, 25 | confirmProps: { color: props.danger ? "red" : "default" }, 26 | onConfirm: () => res(true), 27 | onCancel: () => res(false), 28 | }) 29 | ); -------------------------------------------------------------------------------- /src/app/components/dialogs/input-text-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { modals } from '@mantine/modals'; 2 | import { Flex, Select, TextInput } from '@mantine/core'; 3 | 4 | type TDialogProps = { 5 | title: string; 6 | subtitle: string; 7 | defaultValue: string; 8 | } 9 | export const openInputTextDialog = (props: TDialogProps) => 10 | new Promise((res) => { 11 | let value = props.defaultValue; 12 | const modalId = modals.openConfirmModal({ 13 | title: props.title, 14 | children: <> 15 |
{props.subtitle}
16 | value = e.target.value} 19 | onKeyDown={(e) => { if (e.key === 'Enter') { modals.close(modalId); res(value); } }} /> 20 | , 21 | labels: { confirm: 'Confirm', cancel: 'Cancel' }, 22 | onConfirm: () => res(value), 23 | onCancel: () => res(null), 24 | }); 25 | }); 26 | 27 | 28 | type TCreateFileDialogProps = TDialogProps & { 29 | defaultExtension: string; 30 | }; 31 | 32 | export const openCreateFileDialog = (props: TCreateFileDialogProps) => 33 | new Promise((res) => { 34 | let fileName = props.defaultValue; 35 | let extension = props.defaultExtension; 36 | const onConfirm = () => res(`${fileName}${extension}`); 37 | const modalId = modals.openConfirmModal({ 38 | title: props.title, 39 | children: <> 40 |
{props.subtitle}
41 |
42 | fileName = e.target.value} 46 | onKeyDown={(e) => { if (e.key === 'Enter') { modals.close(modalId); onConfirm(); } }} /> 47 | setHidden(!hidden)} 47 | /> 48 | 49 |
50 | {!hidden &&
51 | {children} 52 |
} 53 | 54 | ; 55 | } 56 | 57 | export const Editor = (p: { heightPx: number, code: string, language: string }) => 58 |
59 |
61 | 64 |
65 |
-------------------------------------------------------------------------------- /src/app/components/onboarding/flowers.tsx: -------------------------------------------------------------------------------- 1 | import { esphomeLanguageId } from "../editors/monaco/languages"; 2 | import { Code, Editor, Heading, Ol, Section, Ul } from "./components"; 3 | 4 | const demo_flower_template_eta = `# demo-flower-template.eta 5 | esphome: 6 | name: <%= it.name %> 7 | 8 | esp32: 9 | board: m5stack-atom 10 | framework: 11 | type: arduino 12 | 13 | api: 14 | ota: 15 | platform: esphome 16 | 17 | wifi: 18 | ssid: !secret wifi_ssid 19 | password: !secret wifi_password 20 | 21 | 22 | sensor: 23 | - platform: adc 24 | pin: 32 25 | name: Soil Moisture Value 26 | attenuation: 11db 27 | id: soil_moisture 28 | accuracy_decimals: 0 29 | unit_of_measurement: "%" 30 | update_interval: 10s 31 | filters: 32 | - calibrate_linear: 33 | - 0 -> 0.0 34 | - 1.5 -> 100.0 35 | on_value_range: 36 | - below: <%= it.moisture_limit %> 37 | then: 38 | - switch.turn_on: relay 39 | - above: <%= it.moisture_limit %> 40 | then: 41 | - switch.turn_off: relay 42 | 43 | switch: 44 | - platform: gpio 45 | pin: 26 46 | id: relay 47 | name: "Water pump"`; 48 | 49 | const demo_flower_template_eta_testdata = `{ 50 | "name": "sample", 51 | "moisture_limit": 99 52 | }` 53 | 54 | const configuration_eta = `# Flower-1/configuration.eta 55 | <%~ include('../.lib/demo-flower-template', 56 | { 57 | name: 'flower-1', 58 | moisture_limit: 35 59 | }) 60 | %>`; 61 | 62 | 63 | export const Flowers = () => { 64 | return <> 65 | 68 | 69 |
72 |

73 | In this step we will write a shared configuration template for all the plants. This template will be used to generate the configuration for each plant. 74 |

75 |
    76 |
  1. Navigate to the root .lib folder
  2. 77 |
  3. Create a new eta template file 78 |
      79 |
    • Click the "..." menu on the .lib folder.
    • 80 |
    • Select New File... and name it demo-flower-template.eta
    • 81 |
    82 |
  4. 83 |
  5. Add the folowing code to the file (Source panel) 84 | 85 |
  6. 86 |
  7. Note the placeholders in the 87 |
      88 |
    • <= it.name %> (e.g., Line 3) is used to insert the name of each plant.
    • 89 |
    • <= it.moisture_limit %> (e.g., Lines 33 and 36) specifies the moisture threshold for watering.
    • 90 |
    91 | These placeholders will dynamically update based on the variables you provide when using the template. 92 | You can test that by entering the following code in to "Test Data" panel: 93 | 94 |
  8. 95 |
96 |
97 | 98 |
101 |

102 | Here we will create a configuration for your first plant. 103 |

104 |
    105 |
  1. 106 | Add a new device for your first plant: 107 |
      108 |
    • In the Devices Panel, click the + New Device button.
    • 109 |
    • Name it Flower-1
    • 110 |
    • A configuration.yaml file will be generated automatically.
    • 111 |
    112 |
  2. 113 |
  3. Click "..." menu on configuration.yaml and rename it to configuration.eta.
  4. 114 |
  5. Edit configuration.eta with the following content: 115 | 116 |
  6. 117 |
118 |
119 | 120 |
123 |
    124 |
  1. Repeat Step 2 for each additional plant you want to automate. Just change the name and moisture_limit
  2. 125 |
  3. Flash the configuration files to your physical hardware devices.
  4. 126 |
  5. Enjoy your automated plant watering system!
  6. 127 |
128 |
129 | ; 130 | } -------------------------------------------------------------------------------- /src/app/components/onboarding/home.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { color_esphome, color_gray, color_local, color_offline, color_online } from "@/app/utils/const"; 3 | import { BeakerIcon, CodeIcon, DownloadIcon, GitCompareIcon, LogIcon, UploadIcon } from "@primer/octicons-react"; 4 | import map from "@/assets/onboarding/map.png"; 5 | import { Code, Heading, L, Section, Ul } from './components'; 6 | import { Button } from '@mantine/core'; 7 | import { usePanelsStore } from '@/app/stores/panels-store'; 8 | 9 | 10 | export const Home = () => { 11 | const panelsStore = usePanelsStore(); 12 | return (<> 13 | 16 | 17 |
18 |

19 | On the Devices Panel, you'll see all your ESPHome devices. The status of each device is indicated by the color of the light bulb 20 |

21 | 22 |
  • Gray - Editor-only device
  • 23 |
  • Online/Offline - status of ESPHome device
  • 24 |
    25 |
    26 | 27 |
    28 |

    29 | Expand a device to access its toolbar. (Available actions depend on the device status) 30 |

    31 | 32 |
  • Import configuration from ESPHome instance
  • 33 |
  • View the compiled local ESPHome configuration
  • 34 |
  • Compare local vs. ESPHome configuration
  • 35 |
  • Upload local configuration to ESPHome
  • 36 |
  • View ESPHome configuration
  • 37 |
  • Compile ESPHome configuration
  • 38 |
  • Install configuration to a device
  • 39 |
  • View log stream
  • 40 |
    41 |
    42 | 43 |
    44 |

    45 | The final YAML configuration is a combination of multiple YAML files. 46 |

    47 | etajs template 48 |
      49 |
    • Use the .lib folder for shared code, with optional device-specific local .lib folders.
    • 50 |
    • The compiler processes all .eta files in the device folder, converting them into .yaml files.
    • 51 |
    • It merges manually created .yaml files and compiled .yaml files into a single final configuration file.
    • 52 |
    53 |
    54 | 55 |
    56 |

    57 | Let's try building your first configuration. This will help you understand how to create and manage devices in the Editor for ESPHome. 58 | {/* Choose between creating multiple devices (e.g., humidity sensors for flowers) or a single device with multiple components (e.g., a PLC with multiple inputs). */} 59 |

    60 |
    61 | 62 |
    63 |
    64 | ); 65 | } -------------------------------------------------------------------------------- /src/app/components/onboarding/index.tsx: -------------------------------------------------------------------------------- 1 | import { Home } from './home'; 2 | import { Flowers } from './flowers'; 3 | import { TPanel_Onboarding } from '@/app/stores/panels-store/types'; 4 | import { Breadcrumbs, Anchor } from '@mantine/core'; 5 | import { usePanelsStore } from '@/app/stores/panels-store'; 6 | import { ChevronRightIcon } from '@primer/octicons-react'; 7 | 8 | export const Onboarding = ({ panel }: { panel: TPanel_Onboarding }) => { 9 | const panelsStore = usePanelsStore(); 10 | 11 | const isHome = (!panel.step || (panel.step === "home")); 12 | const isFlowers = (panel.step === "flowers"); 13 | return
    14 | } aria-label="breadcrumbs"> 15 | {isHome &&
     
    } 16 | {!isHome && 17 | panelsStore.addPanel({ operation: "onboarding", step: "home" })} > 18 | Home 19 | } 20 | {isFlowers &&
    Flowers
    } 21 |
    22 |
    23 |
    24 | {isHome && } 25 | {isFlowers && } 26 |
    27 |
    28 |
    ; 29 | } -------------------------------------------------------------------------------- /src/app/components/panels-container.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DockviewDefaultTab, DockviewReact, IDockviewHeaderActionsProps, IDockviewPanelHeaderProps, IDockviewPanelProps, themeDark, themeLight } from "dockview-react"; 3 | import { LocalFilePanel, LocalFileToolbar } from "./panels/local-file-panel"; 4 | import { LocalDevicePanel, LocalDeviceToolbar } from "./panels/local-device-panel"; 5 | import { ESPHomeDevicePanel, ESPHomeDeviceToolbar } from "./panels/esphome-device-panel"; 6 | import { DiffPanel, DiffToolbar } from "./panels/diff-panel"; 7 | import { EspHomeLogPanel, EspHomeLogToolbar } from "./panels/esphome-log-panel"; 8 | import { EspHomeInstallPanel, EspHomeInstallToolbar } from "./panels/esphome-install-panel"; 9 | import { EspHomeCompilePanel, EspHomeCompileToolbar } from "./panels/esphome-compile-panel"; 10 | import { DevicesPanel } from "./panels/devices-panel"; 11 | import { TPanelWithClick } from "../stores/panels-store/types"; 12 | import { usePanelsStore } from "../stores/panels-store"; 13 | import { useDarkTheme } from "@/app/utils/hooks"; 14 | import { Onboarding } from "./onboarding"; 15 | import { QuestionIcon } from "@primer/octicons-react"; 16 | import { ActionIcon } from "@mantine/core"; 17 | 18 | type TPanelProps = { 19 | toolbar: React.ReactNode; 20 | panel: React.ReactNode; 21 | } 22 | const Panel = (p: TPanelProps) => { 23 | return
    24 |
    {p.toolbar}
    25 |
    {/* relative is needed because of log streams */} 26 | {p.panel} 27 |
    28 |
    ; 29 | } 30 | 31 | const dockViewComponents = { 32 | default: (p: IDockviewPanelProps) => { 33 | const panel = p.params; 34 | switch (panel.operation) { 35 | case "devices_tree": 36 | return ; 37 | case "esphome_device": 38 | return } 40 | panel={} 41 | />; 42 | case "local_file": 43 | return } 45 | panel={} 46 | />; 47 | case "local_device": 48 | return } 50 | panel={} 51 | />; 52 | case "diff": 53 | return } 55 | panel={} 56 | />; 57 | case "esphome_compile": 58 | return } 60 | panel={} 61 | />; 62 | case "esphome_install": 63 | return } 65 | panel={} />; 66 | case "esphome_log": 67 | return } 69 | panel={} />; 70 | case "onboarding": 71 | return ; 72 | default: 73 | return
    Noting selected
    ; 74 | } 75 | } 76 | }; 77 | 78 | const dockViewTabComponents = { 79 | default: (p: IDockviewPanelHeaderProps) => { 80 | const panel = p.params; 81 | switch (panel.operation) { 82 | case "onboarding": 83 | return ; 84 | default: 85 | return ; 86 | } 87 | } 88 | }; 89 | 90 | const RightHeaderActions = (prop: IDockviewHeaderActionsProps) => { 91 | return ( } 93 | variant="subtle" > 94 | ); 95 | }; 96 | 97 | export const PanelsContainer = () => { 98 | const isDarkMode = useDarkTheme(); 99 | const panelsStore = usePanelsStore(); 100 | 101 | return panelsStore.initApi(e.api)} 105 | components={dockViewComponents} 106 | tabComponents={dockViewTabComponents} 107 | rightHeaderActionsComponent={RightHeaderActions} 108 | />; 109 | }; -------------------------------------------------------------------------------- /src/app/components/panels/devices-panel.tsx: -------------------------------------------------------------------------------- 1 | import { DevicesTree } from "../devices-tree" 2 | 3 | export const DevicesPanel = () => { 4 | return
    5 | 6 |
    ; 7 | } -------------------------------------------------------------------------------- /src/app/components/panels/diff-panel.tsx: -------------------------------------------------------------------------------- 1 | import { useDeviceDiffStoreQuery } from "@/app/stores/panels-store/device-diff-store"; 2 | import { DiffEditor } from "../editors/diff-editor"; 3 | import { useDevice } from "@/app/stores/devices-store"; 4 | import { Toolbar } from "../toolbar"; 5 | import { DeviceToolbarItem } from "../devices-tree/device-toolbar"; 6 | 7 | type TProps = { 8 | device_id: string; 9 | } 10 | 11 | export const DiffToolbar = ({ device_id }: TProps) => { 12 | const device = useDevice(device_id)!; 13 | 14 | const panelTarget = "floating"; 15 | 16 | return 17 | 18 | 19 | 20 | 21 | 22 | 23 | } 24 | 25 | export const DiffPanel = ({ device_id }: TProps) => { 26 | const data = useDeviceDiffStoreQuery(device_id); 27 | 28 | return ; 29 | } -------------------------------------------------------------------------------- /src/app/components/panels/esphome-compile-panel.tsx: -------------------------------------------------------------------------------- 1 | import { useEspHomeCompileStore } from "@/app/stores/panels-store/esphome-compile-store"; 2 | import { LogStream } from "../editors/log-stream"; 3 | import { SyncIcon } from "@primer/octicons-react"; 4 | import { DeviceToolbarItem } from "../devices-tree/device-toolbar"; 5 | import { useDevice } from "@/app/stores/devices-store"; 6 | import { Toolbar, ToolbarItem } from "../toolbar"; 7 | 8 | type TProps = { 9 | device_id: string; 10 | lastClick: string; 11 | } 12 | 13 | export const EspHomeCompileToolbar = ({ device_id, lastClick }: TProps) => { 14 | const device = useDevice(device_id)!; 15 | const logStore = useEspHomeCompileStore(device_id, lastClick); 16 | const panelTarget ="floating"; 17 | 18 | return 19 | } tooltip="Refresh" /> 20 | 21 | 22 | 23 | 24 | ; 25 | } 26 | 27 | export const EspHomeCompilePanel = ({ device_id, lastClick }: TProps) => { 28 | const logStore = useEspHomeCompileStore(device_id, lastClick); 29 | return ; 30 | } -------------------------------------------------------------------------------- /src/app/components/panels/esphome-device-panel.tsx: -------------------------------------------------------------------------------- 1 | import { useESPHomeDeviceStore } from "@/app/stores/panels-store/esphome-device-store"; 2 | import { SingleEditor } from "../editors/single-editor"; 3 | import { useDevice } from "@/app/stores/devices-store"; 4 | import { Toolbar } from "../toolbar"; 5 | import { DeviceToolbarItem } from "../devices-tree/device-toolbar"; 6 | 7 | type TProps = { 8 | device_id: string; 9 | } 10 | 11 | export const ESPHomeDeviceToolbar = ({ device_id }: TProps) => { 12 | const device = useDevice(device_id)!; 13 | 14 | const panelTarget = "floating"; 15 | 16 | return 17 | 18 | 19 | 20 | 21 | 22 | } 23 | 24 | export const ESPHomeDevicePanel = ({device_id} : TProps) => { 25 | const data = useESPHomeDeviceStore(device_id); 26 | 27 | return ; 28 | } -------------------------------------------------------------------------------- /src/app/components/panels/esphome-install-panel.tsx: -------------------------------------------------------------------------------- 1 | import { useEsphomeInstallStore } from "@/app/stores/panels-store/esphome-install-store"; 2 | import { LogStream } from "../editors/log-stream"; 3 | import { useDevice } from "@/app/stores/devices-store"; 4 | import { DeviceToolbarItem } from "../devices-tree/device-toolbar"; 5 | import { Toolbar, ToolbarItem } from "../toolbar"; 6 | import { SyncIcon, XIcon } from "@primer/octicons-react"; 7 | 8 | type TProps = { 9 | device_id: string; 10 | lastClick: string; 11 | } 12 | 13 | export const EspHomeInstallToolbar = ({ device_id, lastClick }: TProps) => { 14 | const device = useDevice(device_id)!; 15 | const logStore = useEsphomeInstallStore(device_id, lastClick); 16 | 17 | const panelTarget ="floating"; 18 | 19 | return 20 | } tooltip="Refresh" /> 21 | 22 | 23 | } onClick={() => logStore.clear()} /> 24 | 25 | 26 | ; 27 | } 28 | 29 | export const EspHomeInstallPanel = ({ device_id, lastClick }: TProps) => { 30 | const logStore = useEsphomeInstallStore(device_id, lastClick); 31 | return ; 32 | } -------------------------------------------------------------------------------- /src/app/components/panels/esphome-log-panel.tsx: -------------------------------------------------------------------------------- 1 | import { useEspHomeLogStore } from "@/app/stores/panels-store/esphome-log-store"; 2 | import { LogStream } from "../editors/log-stream"; 3 | import { SyncIcon, XIcon } from "@primer/octicons-react"; 4 | import { Toolbar, ToolbarItem } from "../toolbar"; 5 | import { DeviceToolbarItem } from "../devices-tree/device-toolbar"; 6 | import { useDevice } from "@/app/stores/devices-store"; 7 | 8 | type TProps = { 9 | device_id: string; 10 | lastClick: string; 11 | } 12 | 13 | export const EspHomeLogToolbar = ({ device_id, lastClick }: TProps) => { 14 | const device = useDevice(device_id)!; 15 | const logStore = useEspHomeLogStore(device_id, lastClick); 16 | return 17 | } tooltip="Refresh" /> 18 | 19 | } onClick={() => logStore.clear()} /> 20 | 21 | 22 | ; 23 | } 24 | 25 | export const EspHomeLogPanel = ({ device_id, lastClick }: TProps) => { 26 | const logStore = useEspHomeLogStore(device_id, lastClick); 27 | return ; 28 | } -------------------------------------------------------------------------------- /src/app/components/panels/local-device-panel.tsx: -------------------------------------------------------------------------------- 1 | import { useDevice } from "@/app/stores/devices-store"; 2 | import { SingleEditor } from "../editors/single-editor"; 3 | import { useLocalDeviceStore } from "@/app/stores/panels-store/local-device-store"; 4 | import { Toolbar } from "../toolbar"; 5 | import { DeviceToolbarItem } from "../devices-tree/device-toolbar"; 6 | 7 | type TProps = { 8 | device_id: string; 9 | } 10 | 11 | export const LocalDeviceToolbar = (props: TProps) => { 12 | const device = useDevice(props.device_id)!; 13 | 14 | const panelTarget = "floating"; 15 | 16 | return 17 | 18 | 19 | 20 | 21 | 22 | 23 | ; 24 | } 25 | 26 | export const LocalDevicePanel = ({device_id} : TProps) => { 27 | const data = useLocalDeviceStore(device_id); 28 | 29 | return ; 30 | } -------------------------------------------------------------------------------- /src/app/components/panels/local-file-panel.tsx: -------------------------------------------------------------------------------- 1 | import { useLocalFile, useLocalFileStore } from "@/app/stores/panels-store/local-file-store"; 2 | import { DockviewApi, DockviewDefaultTab, DockviewReact, IDockviewPanelProps, themeDark, themeLight } from "dockview-react"; 3 | import { useDarkTheme } from "@/app/utils/hooks"; 4 | import { SingleEditor } from "../editors/single-editor"; 5 | import { QuestionIcon } from "@primer/octicons-react"; 6 | import { DeviceToolbarItem } from "../devices-tree/device-toolbar"; 7 | import { useDevice } from "@/app/stores/devices-store"; 8 | import { Toolbar, ToolbarItem } from "../toolbar"; 9 | import { HtmlPreview } from "../editors/html-preview"; 10 | 11 | type TProps = { 12 | device_id: string; 13 | file_path: string; 14 | } 15 | 16 | export const LocalFileToolbar = (props: TProps) => { 17 | const device = useDevice(props.device_id)!; 18 | const file = useLocalFile(props.device_id, props.file_path); 19 | 20 | if (!file) 21 | return null; 22 | 23 | const language = file.language; 24 | //const fileType = file.type; 25 | 26 | const getHelpIcon = () => { 27 | if (language === "esphome") { 28 | return } />; 32 | } else if (language === "etajs") { 33 | return } />; 37 | } else { 38 | return null; 39 | } 40 | } 41 | 42 | const panelTarget = "floating"; 43 | 44 | return 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | {getHelpIcon()} 54 | 55 | } 56 | 57 | const dockViewComponents = { 58 | source: (p: IDockviewPanelProps) => { 59 | const data = useLocalFileStore(p.params.device_id, p.params.file_path); 60 | return ; 61 | }, 62 | compiled: (p: IDockviewPanelProps) => { 63 | const data = useLocalFileStore(p.params.device_id, p.params.file_path); 64 | return ; 65 | }, 66 | htmlPreview: (p: IDockviewPanelProps) => { 67 | const data = useLocalFileStore(p.params.device_id, p.params.file_path); 68 | return ; 69 | }, 70 | testdata: (p: IDockviewPanelProps) => { 71 | const data = useLocalFileStore(p.params.device_id, p.params.file_path); 72 | return ; 73 | }, 74 | }; 75 | 76 | export const LocalFilePanel = (props: TProps) => { 77 | const isDarkMode = useDarkTheme(); 78 | const localFile = useLocalFile(props.device_id, props.file_path); 79 | const data = useLocalFileStore(props.device_id, props.file_path); 80 | 81 | const onReady = (api: DockviewApi) => { 82 | const panelLeft = api.addPanel({ 83 | id: "left", 84 | title: "Source", 85 | component: "source", 86 | params: props 87 | }); 88 | if (localFile.language === "markdown") { 89 | api.addPanel({ 90 | id: "right", 91 | title: "Preview", 92 | component: "htmlPreview", 93 | params: props, 94 | position: { referencePanel: panelLeft, direction: 'right' }, 95 | }); 96 | } else { 97 | api.addPanel({ 98 | id: "right", 99 | title: "Compiled", 100 | component: "compiled", 101 | params: props, 102 | position: { referencePanel: panelLeft, direction: 'right' }, 103 | }); 104 | } 105 | if (data.testDataEditor) { 106 | api.addPanel({ 107 | id: "testdata", 108 | title: "Test Data", 109 | component: "testdata", 110 | params: props, 111 | initialHeight: 250, 112 | position: { referencePanel: panelLeft, direction: 'above' }, 113 | }); 114 | } 115 | 116 | }; 117 | 118 | return data.rightEditor 119 | ?
    120 | onReady(e.api)} 124 | defaultTabComponent={p => } 125 | components={dockViewComponents} /> 126 |
    127 | : 128 | 129 | }; -------------------------------------------------------------------------------- /src/app/components/panels/log-list.tsx: -------------------------------------------------------------------------------- 1 | import type { TOperationResult } from "@/server/devices/types"; 2 | import { Table } from "@mantine/core"; 3 | 4 | type TProps = { 5 | logs?: TOperationResult["logs"] 6 | } 7 | 8 | const LogIcon = ({ type }: { type: TOperationResult["logs"][0]["type"] }) => { 9 | switch (type) { 10 | case "info": 11 | return ℹ️; 12 | case "error": 13 | return ; 14 | default: 15 | return ℹ️; 16 | } 17 | } 18 | 19 | export const LogList = (props: TProps) => { 20 | return 21 | 22 | 23 | 24 | Message 25 | Path 26 | 27 | 28 | 29 | {props.logs?.length === 0 30 | ? Something went wrong - No logs 31 | : props.logs?.map((l, i) => 32 | 33 | 34 | {l.message} 35 | {l.path} 36 | ) 37 | } 38 | 39 |
    40 | 41 | 42 | } -------------------------------------------------------------------------------- /src/app/components/panels/query-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { type TEditorFileProps } from "@/app/stores/panels-store/types"; 2 | import { LogList } from "./log-list"; 3 | 4 | type TProps = { 5 | query?: TEditorFileProps["query"]; 6 | query2?: TEditorFileProps["query"]; 7 | children: React.ReactNode; 8 | } 9 | export const QueryWrapper = (props: TProps) => { 10 | if (props.query?.pending || props.query2?.pending) 11 | return
    Loading...
    ; 12 | else if (!(props.query?.success ?? true) || !(props.query2?.success ?? true)) 13 | return ; 14 | else 15 | return props.children; 16 | } -------------------------------------------------------------------------------- /src/app/components/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ActionIcon, ActionIconGroup, ActionIconProps, Divider, TextInput, Tooltip } from "@mantine/core"; 3 | import { SearchIcon, XIcon } from "@primer/octicons-react"; 4 | import { type useStreamingStore } from "../stores/panels-store/utils/streaming-store"; 5 | 6 | const allProps: Pick = 7 | { 8 | variant: "subtle", 9 | } 10 | 11 | export type TToolbarButtonProps = Parameters>[0] & { 12 | tooltip: string; 13 | icon: React.ReactNode; 14 | } 15 | 16 | const ToolbarButton = (p: TToolbarButtonProps) => { 17 | const { tooltip, icon, ...x } = p; 18 | 19 | const restProps = x as any; 20 | if (restProps.onClick) 21 | restProps.onAuxClick = restProps.onClick; 22 | 23 | return 24 | 25 | {...allProps} 26 | {...restProps} > 27 | {p.icon} 28 | 29 | ; 30 | } 31 | 32 | export const Toolbar = ActionIconGroup; 33 | 34 | type HrefButtonProps = Pick, "href" | "tooltip" | "icon">; 35 | 36 | type TFilterProps = { 37 | logStore: ReturnType; 38 | } 39 | 40 | export const ToolbarItem = { 41 | AllProps: allProps, 42 | Stretch: () => , 43 | Divider: () => , 44 | Button: (p: TToolbarButtonProps) => {...p as any} />, //Arrow functiuon needed because otherwise "component" prop is somehow lost 45 | HrefButton: (p: HrefButtonProps) => , 46 | Filter: (p: TFilterProps) => { 47 | return <> 48 |
    49 | {p.logStore.filter 50 | ? `${p.logStore.filteredData.length} of ${p.logStore.allData.length}` 51 | : `${p.logStore.allData.length}`} 52 |
    53 | p.logStore.setFilter(e.currentTarget.value)} 58 | placeholder="Filter" 59 | leftSection={} 60 | leftSectionPointerEvents="none" 61 | rightSection={ p.logStore.setFilter("")} >} /> 62 | ; 63 | } 64 | }; -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | /* 2 | Disable preflight to avoid conflicts with mantine 3 | @import 'tailwindcss'; 4 | */ 5 | 6 | @layer theme, base, components, utilities; 7 | @import "tailwindcss/theme.css" layer(theme); 8 | /*@import "tailwindcss/preflight.css" layer(base);*/ 9 | @import "tailwindcss/utilities.css" layer(utilities); 10 | 11 | @theme { 12 | --color-background: var(--background); 13 | --color-foreground: var(--foreground); 14 | } 15 | 16 | /* 17 | The default border color has changed to `currentColor` in Tailwind CSS v4, 18 | so we've added these compatibility styles to make sure everything still 19 | looks the same as it did with Tailwind CSS v3. 20 | 21 | If we ever want to remove these styles, we need to add an explicit border 22 | color utility to any element that depends on these defaults. 23 | */ 24 | @layer base { 25 | *, 26 | ::after, 27 | ::before, 28 | ::backdrop, 29 | ::file-selector-button { 30 | border-color: var(--color-gray-200, currentColor); 31 | } 32 | } 33 | 34 | :root { 35 | --background: #fafafa; 36 | --foreground: #171717; 37 | } 38 | 39 | @media (prefers-color-scheme: dark) { 40 | :root { 41 | --background: #111111; 42 | --foreground: #ededed; 43 | } 44 | } 45 | 46 | body { 47 | color: var(--foreground); 48 | background: var(--background); 49 | } 50 | 51 | .monaco-editor { 52 | position: absolute !important; 53 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { NuqsAdapter } from 'nuqs/adapters/next/app' 3 | import "./globals.css"; 4 | import 'dockview-react/dist/styles/dockview.css'; 5 | import '@mantine/core/styles.css'; 6 | import '@mantine/notifications/styles.css'; 7 | import { ClientLayout } from "./client-layout"; 8 | import { ColorSchemeScript, createTheme, MantineProvider } from "@mantine/core"; 9 | import { ModalsProvider } from "@mantine/modals"; 10 | import { Notifications } from "@mantine/notifications"; 11 | 12 | export const metadata: Metadata = { 13 | title: "Editor for ESPHome", 14 | }; 15 | 16 | const theme = createTheme({ 17 | components: { 18 | //Dockview Floating Groups are 999 19 | "Tooltip": { 20 | defaultProps: { 21 | zIndex: 1002, 22 | } 23 | }, 24 | "Popover": { 25 | defaultProps: { 26 | zIndex: 1002, 27 | } 28 | } 29 | } 30 | }); 31 | 32 | 33 | export default function RootLayout({ 34 | children, 35 | }: Readonly<{ 36 | children: React.ReactNode; 37 | }>) { 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | 51 | 52 | {children} 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useState } from "react"; 3 | import { ISplitviewPanelProps, Orientation, SplitviewApi, SplitviewReact, SplitviewReadyEvent } from "dockview-react"; 4 | import { Anchor, Button, Loader } from "@mantine/core"; 5 | import Image from "next/image"; 6 | import { SidebarExpandIcon } from "@primer/octicons-react"; 7 | import { DevicesTree } from "./components/devices-tree"; 8 | import { useStatusStore } from "./stores/status-store"; 9 | import { usePanelsStore, useRerenderOnPanelChange } from "./stores/panels-store"; 10 | import { openAboutDialog } from "./components/dialogs/about-dialog"; 11 | import logo from "@/assets/logo.svg"; 12 | import { TPanel } from "./stores/panels-store/types"; 13 | import { useDarkTheme } from "./utils/hooks"; 14 | import { useMonacoInit } from "./components/editors/monaco/monaco-init"; 15 | import { useDevicesQuery } from "./stores/devices-store"; 16 | import { PanelsContainer } from "./components/panels-container"; 17 | import { useWindowEvent } from "@mantine/hooks"; 18 | 19 | const devicesPanel: TPanel = { 20 | operation: "devices_tree" 21 | }; 22 | 23 | const Header = () => { 24 | const panelsStore = usePanelsStore(); 25 | return
    panelsStore.addPanel({ operation: "onboarding", step: "home" })}> 26 | ESPHome Editor 27 |

    Editor for ESPHome

    28 |
    29 | } 30 | 31 | const CollapseButton = () => { 32 | const panelsStore = usePanelsStore(); 33 | return 36 | } 37 | 38 | const DevicesPanel = () => { 39 | const statusStore = useStatusStore(); 40 | 41 | return
    42 |
    43 |
    44 |
    45 |
    46 | 47 |
    48 |
    49 |
    50 | 51 |
    52 | openAboutDialog()}>{statusStore.query.isSuccess && statusStore.query.data?.version} 53 |
    54 |
    ; 55 | } 56 | 57 | const components:Record> = { 58 | "devices-sidePanel": () => , 59 | "panels-container": () => 60 | }; 61 | 62 | const findDevicesSidePanel = (api: SplitviewApi) => api.getPanel("devices-sidePanel"); 63 | 64 | const PageContent = () => { 65 | const [api, setApi] = useState() 66 | const panelsApi = useRerenderOnPanelChange(); 67 | const devicePanelExists = !!panelsApi.findPanel(devicesPanel); 68 | 69 | const isDarkMode = useDarkTheme(); 70 | const onReady = (event: SplitviewReadyEvent) => setApi(event.api); 71 | 72 | useEffect(() => { 73 | if (!api) return; 74 | 75 | api.addPanel({ 76 | id: 'panels-container', 77 | component: 'panels-container', 78 | index: 1, 79 | }); 80 | 81 | api.onDidLayoutChange((e) => { 82 | const devicesSidePanel = findDevicesSidePanel(api); 83 | if (devicesSidePanel) { 84 | localStorage.setItem('e4e.devicesWidth', devicesSidePanel.width.toString()); 85 | } 86 | }); 87 | }, [api]); 88 | 89 | useWindowEvent("resize", () => api?.layout(window.innerWidth, window.innerHeight)); 90 | 91 | useEffect(() => { 92 | if (!api) return; 93 | 94 | if (devicePanelExists) 95 | api.removePanel(findDevicesSidePanel(api)!); 96 | else { 97 | api.addPanel({ 98 | id: 'devices-sidePanel', 99 | component: 'devices-sidePanel', 100 | minimumSize: 75, 101 | maximumSize: 450, 102 | size: parseFloat(localStorage.getItem('e4e.devicesWidth') ?? "250"), 103 | index: 0, 104 | }); 105 | } 106 | }, [api, devicePanelExists]); 107 | 108 | return 114 | } 115 | 116 | const Page = () => { 117 | const monacoInitialized = useMonacoInit(); 118 | const devicesQuery = useDevicesQuery(); 119 | 120 | return (!monacoInitialized || devicesQuery.isLoading) 121 | ?
    122 | 123 |
    124 | : 125 | }; 126 | export default Page; -------------------------------------------------------------------------------- /src/app/stores/events.ts: -------------------------------------------------------------------------------- 1 | import { TLocalFileOrDirectory } from "@/server/devices/types"; 2 | import { createNanoEvents } from "nanoevents"; 3 | 4 | interface TEvents { 5 | File_Created: (device_id: string, path: string) => void; 6 | FoD_Deleted: (device_id: string, fod: TLocalFileOrDirectory) => void; 7 | FoD_Renamed: (device_id: string, fod: TLocalFileOrDirectory, new_path: string) => void; 8 | Device_Deleted: (device_id: string) => void; 9 | } 10 | 11 | export const events = createNanoEvents(); -------------------------------------------------------------------------------- /src/app/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { useDevicesStore } from "./devices-store"; 3 | import { usePanelsStore } from "./panels-store"; 4 | 5 | export const queryClient = new QueryClient() 6 | 7 | 8 | export const useAppStores = () => { 9 | return { 10 | panels: usePanelsStore(), 11 | devices: useDevicesStore(), 12 | } 13 | } -------------------------------------------------------------------------------- /src/app/stores/panels-store/device-diff-store.ts: -------------------------------------------------------------------------------- 1 | import { useESPHomeDeviceStore } from "./esphome-device-store"; 2 | import { useLocalDeviceStore } from "./local-device-store"; 3 | 4 | export const useDeviceDiffStoreQuery = (device_id: string) => { 5 | const leftQuery = useESPHomeDeviceStore(device_id) 6 | const rightQuery = useLocalDeviceStore(device_id); 7 | 8 | return { 9 | leftEditor: leftQuery, 10 | rightEditor: rightQuery 11 | } 12 | } -------------------------------------------------------------------------------- /src/app/stores/panels-store/esphome-compile-store.ts: -------------------------------------------------------------------------------- 1 | import { useStreamingStore } from "./utils/streaming-store"; 2 | import { api } from "@/app/utils/api-client"; 3 | 4 | export const useEspHomeCompileStore = (device_id: string, lastClick: string) => 5 | useStreamingStore(api.url_device(device_id, "esphome/compile"), lastClick); 6 | -------------------------------------------------------------------------------- /src/app/stores/panels-store/esphome-device-store.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@/app/utils/api-client"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { TEditorFileProps } from "./types"; 4 | import { callResultToEditorFileProps } from "./utils/query-utils"; 5 | import { esphomeLanguageId } from "@/app/components/editors/monaco/languages"; 6 | 7 | export const useESPHomeDeviceStore = (device_id: string) => { 8 | const query = useQuery({ 9 | queryKey: ["device", device_id, "esphome"], 10 | queryFn: async () => api.esphome_device(device_id) 11 | }) 12 | return { 13 | ...callResultToEditorFileProps(query), 14 | language: esphomeLanguageId, 15 | } satisfies TEditorFileProps; 16 | } -------------------------------------------------------------------------------- /src/app/stores/panels-store/esphome-install-store.ts: -------------------------------------------------------------------------------- 1 | import { useStreamingStore } from "./utils/streaming-store"; 2 | import { api } from "@/app/utils/api-client"; 3 | 4 | export const useEsphomeInstallStore = (device_id: string, lastClick: string) => 5 | useStreamingStore(api.url_device(device_id, "esphome/install"), lastClick); 6 | -------------------------------------------------------------------------------- /src/app/stores/panels-store/esphome-log-store.ts: -------------------------------------------------------------------------------- 1 | import { useStreamingStore } from "./utils/streaming-store"; 2 | import { api } from "@/app/utils/api-client"; 3 | 4 | export const useEspHomeLogStore = (device_id: string, lastClick: string) => 5 | useStreamingStore(api.url_device(device_id, "esphome/log"), lastClick); 6 | -------------------------------------------------------------------------------- /src/app/stores/panels-store/local-device-store.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@/app/utils/api-client"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { TEditorFileProps } from "./types"; 4 | import { resultToEditorFileProps } from "./utils/query-utils"; 5 | import { esphomeLanguageId } from "@/app/components/editors/monaco/languages"; 6 | 7 | export const useLocalDeviceStore = (device_id: string) => { 8 | const query = useQuery({ 9 | queryKey: ["device", device_id, "local"], 10 | queryFn: async () => api.local_device(device_id) 11 | }) 12 | return { 13 | ...resultToEditorFileProps(query), 14 | language: esphomeLanguageId, 15 | } satisfies TEditorFileProps; 16 | } -------------------------------------------------------------------------------- /src/app/stores/panels-store/local-file-store.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@/app/utils/api-client"; 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 3 | import { TEditorFileProps } from "./types"; 4 | import { TLocalFile, TLocalFileOrDirectory } from "@/server/devices/types"; 5 | import { useDevice } from "../devices-store"; 6 | import { callResultToEditorFileProps } from "./utils/query-utils"; 7 | import { getSourceMonacoLanguge, getTargetMonacoLanguage } from "@/app/utils/file-utils"; 8 | 9 | const findFile = (fods: TLocalFileOrDirectory[], file_path: string): TLocalFile | null => { 10 | for (const fod of fods) { 11 | if (fod.type === "file" && fod.path === file_path) { 12 | return fod; 13 | } 14 | if (fod.type === "directory") { 15 | const file = findFile(fod.files!, file_path); 16 | if (file) { 17 | return file; 18 | } 19 | } 20 | } 21 | return null; 22 | } 23 | 24 | export const useLocalFile = (device_id: string, file_path: string) => { 25 | const device = useDevice(device_id); 26 | const file = findFile(device?.files ?? [], file_path)!; 27 | return file; 28 | } 29 | 30 | export const useLocalFileStore = (device_id: string, file_path: string) => { 31 | const file = useLocalFile(device_id, file_path); 32 | 33 | const hasRightFile = (file?.language === "etajs") || (file?.language === "markdown"); 34 | 35 | const leftQuery = useQuery({ 36 | queryKey: ["device", device_id, "local-file", file_path], 37 | queryFn: async () => api.local_path_get(device_id, file_path) 38 | }); 39 | 40 | const rightQuery = useQuery({ 41 | queryKey: ["device", device_id, "local-file", file_path, "compiled"], 42 | queryFn: async () => api.local_path_compiled(device_id, file_path), 43 | enabled: hasRightFile 44 | }); 45 | 46 | const hasTestData = (file?.language === "etajs") && ((device_id === ".lib") || (file_path.indexOf("/") > 0)); 47 | const testDataQuery = useQuery({ 48 | queryKey: ["device", device_id, "local-file", file_path, "test-data"], 49 | queryFn: async () => api.local_path_testData_get(device_id, file_path), 50 | enabled: hasTestData 51 | }); 52 | 53 | const queryClient = useQueryClient(); 54 | const leftMutation = useMutation({ 55 | mutationFn: async (v: string) => api.local_path_save(device_id, file_path, v), 56 | onSuccess: () => { 57 | queryClient.invalidateQueries({ queryKey: ["device", device_id, "local-file", file_path, "compiled"] }); 58 | queryClient.invalidateQueries({ queryKey: ["device", device_id, "local"] }); 59 | } 60 | }); 61 | const testDataMutation = useMutation({ 62 | mutationFn: async (v: string) => api.local_path_testData_post(device_id, file_path, v), 63 | onSuccess: () => { 64 | queryClient.invalidateQueries({ queryKey: ["device", device_id, "local-file", file_path, "compiled"] }); 65 | } 66 | }); 67 | 68 | return { 69 | leftEditor: { 70 | ...callResultToEditorFileProps(leftQuery), 71 | language: getSourceMonacoLanguge(file), 72 | onValueChange: (v) => leftMutation.mutate(v), 73 | } satisfies TEditorFileProps, 74 | rightEditor: hasRightFile 75 | ? { 76 | ...callResultToEditorFileProps(rightQuery), 77 | language: getTargetMonacoLanguage(file), 78 | } satisfies TEditorFileProps 79 | : null, 80 | testDataEditor: hasTestData 81 | ? { 82 | ...callResultToEditorFileProps(testDataQuery), 83 | language: "json", 84 | onValueChange: (v) => testDataMutation.mutate(v), 85 | } satisfies TEditorFileProps 86 | : null, 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /src/app/stores/panels-store/types.ts: -------------------------------------------------------------------------------- 1 | import type { TOperationResult } from "@/server/devices/types"; 2 | 3 | export type TEditorFileProps = { 4 | query?: { 5 | pending: boolean; 6 | success: boolean; 7 | logs: TOperationResult["logs"] 8 | } 9 | value: string; 10 | language: string; 11 | onValueChange?: (v: string) => void; 12 | } 13 | 14 | 15 | type TPanel_DeviceBase = { 16 | device_id: string; 17 | } 18 | 19 | 20 | type TPanel_DeviceLocalFile = { 21 | operation: "local_file"; 22 | path: string; 23 | } 24 | 25 | export type TPanel_DeviceOperation = { 26 | operation: "local_device" | "esphome_device" | "diff" | "esphome_compile" | "esphome_install" | "esphome_log"; 27 | } 28 | 29 | export type TPanel_Device = TPanel_DeviceBase & (TPanel_DeviceLocalFile | TPanel_DeviceOperation); 30 | 31 | export type TPanel_Onboarding = { 32 | operation: "onboarding"; 33 | step?: "home" | "flowers"; 34 | } 35 | 36 | type TPanel_Devices = { 37 | operation: "devices_tree"; 38 | } 39 | 40 | export type TPanel = (TPanel_Device | TPanel_Onboarding | TPanel_Devices); 41 | 42 | export type TPanelWithClick = TPanel & { last_click: string }; -------------------------------------------------------------------------------- /src/app/stores/panels-store/utils/query-utils.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@/app/utils/api-client"; 2 | import { UseQueryResult } from "@tanstack/react-query"; 3 | import { TEditorFileProps } from "../types"; 4 | import type { TOperationResult } from "@/server/devices/types"; 5 | 6 | export const callResultToEditorFileProps = (query: UseQueryResult) => 7 | ({ 8 | query: { 9 | pending: query.isLoading, 10 | success: query.isFetched ? (query.data?.status === 200) : false, 11 | logs: (query.isFetched && (query.data?.status !== 200)) 12 | ? [{ 13 | type: "error", 14 | message: query.data?.content!, 15 | path: "unknown" 16 | }] 17 | : [] 18 | }, 19 | value: query.data?.content!, 20 | } satisfies Pick); 21 | 22 | export const resultToEditorFileProps = (query: UseQueryResult>) => 23 | ({ 24 | query: { 25 | pending: query.isLoading, 26 | success: query.isFetched && query.isSuccess && query.data?.success, 27 | logs: query.data?.logs ?? [], 28 | }, 29 | value: query.data?.value!, 30 | } satisfies Pick); -------------------------------------------------------------------------------- /src/app/stores/panels-store/utils/streaming-store.ts: -------------------------------------------------------------------------------- 1 | import { TWsMessage } from "@/app/api/device/[device_id]/esphome/utils"; 2 | import { api } from "@/app/utils/api-client"; 3 | import { log } from "@/shared/log"; 4 | import Convert from "ansi-to-html"; 5 | import { atomFamily } from 'jotai/utils'; 6 | import { atom, getDefaultStore, PrimitiveAtom, useAtom } from "jotai"; 7 | import { useMemo } from "react"; 8 | 9 | const convert = new Convert({ 10 | stream: true, 11 | }); 12 | 13 | type TLogStoreAtom = { 14 | data: string[]; 15 | filter: string; 16 | isOutdated: boolean; 17 | } 18 | 19 | const createLogStreamingStore = (url: string, atom: PrimitiveAtom) => { 20 | const socket = new WebSocket(api.getWsUrl(url)) 21 | log.debug("Creating socket", url); 22 | 23 | // Connection opened 24 | socket.addEventListener("open", event => { 25 | socket.send("Connection established") 26 | }); 27 | 28 | // Listen for messages 29 | socket.addEventListener("message", event => { 30 | if (event?.data) { 31 | const jsonData = JSON.parse(event.data) as TWsMessage; 32 | 33 | switch (jsonData.event) { 34 | case "completed": 35 | log.verbose("Stream completed"); 36 | //ws.getWebSocket()!.close(); 37 | break; 38 | case "error": 39 | log.error("Stream error", jsonData.data); 40 | //ws.getWebSocket()!.close(); 41 | break; 42 | case "message": 43 | const html = convert.toHtml(jsonData.data.replaceAll("\\033", "\x1b")); 44 | getDefaultStore().set(atom, val => ({ 45 | ...val, 46 | data: [...val.data, html], 47 | })); 48 | break; 49 | default: 50 | log.warn("Unknown event", jsonData); 51 | break 52 | } 53 | } 54 | }); 55 | return () => { 56 | log.debug("Closing socket"); 57 | if (socket.readyState === WebSocket.OPEN) 58 | socket.close(); 59 | } 60 | } 61 | 62 | 63 | 64 | const isOutdated = (lastClick: string | undefined) => { 65 | if (!lastClick) 66 | return true; 67 | 68 | const currentTime = new Date(); 69 | const lastClickTime = new Date(lastClick); 70 | const diff = currentTime.getTime() - lastClickTime.getTime(); 71 | return diff > 1000; 72 | } 73 | 74 | type AtomKey = { 75 | url: string; 76 | lastClick: string; 77 | } 78 | const storeFamily = atomFamily((key: AtomKey) => { 79 | const store = atom({ 80 | data: [], 81 | filter: "", 82 | isOutdated: isOutdated(key.lastClick), 83 | }); 84 | if (!isOutdated(key.lastClick)) { 85 | const dispose = createLogStreamingStore(key.url, store); 86 | store.onMount = () => { 87 | log.verbose("onMount", key.url); 88 | return () => { 89 | log.verbose("onUnmount", key.url); 90 | dispose(); 91 | } 92 | } 93 | } 94 | return store; 95 | }, (a, b) => a.url === b.url && a.lastClick === b.lastClick); 96 | 97 | 98 | export const useStreamingStore = (url: string, lastClick: string) => { 99 | const [store, setStore] = useAtom(storeFamily({ url, lastClick })); 100 | 101 | const filteredData = useMemo(() => 102 | store.filter 103 | ? store.data.filter((item) => item.toLowerCase().includes(store.filter.toLowerCase())) 104 | : store.data, 105 | [store.filter, store.data] 106 | ); 107 | 108 | return { 109 | allData: store.data, 110 | filter: store.filter, 111 | filteredData, 112 | setFilter: (filter: string) => setStore({ ...store, filter }), 113 | isOutdated: store.isOutdated, 114 | clear: () => setStore({...store, data: [`${(new Date()).toLocaleTimeString()} - Stream cleared`] }), 115 | } 116 | } 117 | 118 | 119 | -------------------------------------------------------------------------------- /src/app/stores/status-store.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { api } from "../utils/api-client"; 3 | 4 | export const useStatusStore = () => { 5 | const query = useQuery({ 6 | queryKey: ['status'], 7 | queryFn: api.getStatus 8 | }); 9 | 10 | return { 11 | query, 12 | isHaAddon: query.data?.mode === "ha_addon", 13 | } 14 | } -------------------------------------------------------------------------------- /src/app/stores/status-store.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { api } from "../utils/api-client"; 3 | 4 | export const useStatusStore = () => { 5 | const query = useQuery({ 6 | queryKey: ['status'], 7 | queryFn: api.getStatus 8 | }); 9 | 10 | return { 11 | query, 12 | isHaAddon: query.data?.mode === "ha_addon", 13 | } 14 | } -------------------------------------------------------------------------------- /src/app/utils/api-client.ts: -------------------------------------------------------------------------------- 1 | import { assertResponseAndJsonOk, assertResponseOk } from "@/shared/http-utils"; 2 | import { TGetStatus } from "../api/status/route"; 3 | import { TOperationResult } from "@/server/devices/types"; 4 | 5 | export namespace api { 6 | export type TCallResult = { 7 | status: number; 8 | content: string; 9 | }; 10 | 11 | const fixUrl = (url: string) => { 12 | url = url 13 | .replace("//", "/") // Replace double // 14 | .replace(/\/$/, ""); // Remove / at the end of url 15 | 16 | return (url.startsWith("/")) ? `.${url}` : url; 17 | }; 18 | 19 | const fixPath = (path: string) => { 20 | const fixed = path 21 | .replaceAll("//", "/") // Replace double // 22 | .replaceAll(/^\/+|\/+$/g, '') // Remove / from start and end of path 23 | .replaceAll("/", "\\") // Replace \ by / 24 | ; 25 | 26 | return encodeURIComponent(fixed); 27 | } 28 | 29 | export const getWsUrl = (url: string) => { 30 | const finalUrl = new URL(fixUrl(url), location.href); 31 | finalUrl.protocol = finalUrl.protocol === "http:" ? "ws:" : "wss:"; 32 | return finalUrl.toString(); 33 | } 34 | 35 | export async function callGet_text(url: string): Promise { 36 | const response = await fetch(fixUrl(url)); 37 | return { 38 | content: await response.text(), 39 | status: response.status, 40 | }; 41 | } 42 | 43 | export async function callGet_json(url: string): Promise { 44 | const response = await fetch(fixUrl(url)); 45 | return await assertResponseAndJsonOk(response); 46 | } 47 | 48 | async function callDelete(url: string) { 49 | const response = await fetch(fixUrl(url), { 50 | method: "DELETE", 51 | headers: { 52 | "Content-Type": "text/plain", 53 | } 54 | }); 55 | await assertResponseOk(response); 56 | } 57 | 58 | async function callPostPut( 59 | method: "POST" | "PUT", 60 | url: string, 61 | content: string | null, 62 | throwOnError: boolean): Promise { 63 | const response = await fetch(fixUrl(url), { 64 | method: method, 65 | headers: { 66 | "Content-Type": "text/plain", 67 | }, 68 | body: content || undefined, 69 | }); 70 | 71 | if (throwOnError) 72 | await assertResponseOk(response); 73 | 74 | return { 75 | content: await response.text(), 76 | status: response.status, 77 | }; 78 | } 79 | 80 | export async function callPost(url: string, content: string | null, throwOnError: boolean = true): Promise { 81 | return await callPostPut("POST", url, content, throwOnError); 82 | } 83 | 84 | async function callPut(url: string, content: string | null): Promise { 85 | return await callPostPut("PUT", url, content, true); 86 | } 87 | 88 | export const url_device = (device_id: string, suffix: string = "") => `/api/device/${encodeURIComponent(device_id)}/${suffix}`; 89 | export const url_local_path = (device_id: string, path: string, suffix: string = "") => 90 | url_device(device_id, `/local/${fixPath(path)}/${suffix}`); 91 | 92 | export async function local_createDevice(device_id: string) { 93 | await callPut(url_device(device_id, "local"), null); 94 | } 95 | 96 | export async function local_importDevice(device_id: string) { 97 | await callPost(url_device(device_id, "local"), null); 98 | } 99 | 100 | export async function local_device(device_id: string) { 101 | return await callGet_json>(url_device(device_id, "local")); 102 | } 103 | 104 | export async function local_createDirectory(device_id: string, directory_path: string) { 105 | await callPut(url_local_path(device_id, directory_path), "directory"); 106 | } 107 | 108 | export async function local_createFile(device_id: string, directory_path: string) { 109 | await callPut(url_local_path(device_id, directory_path), "file"); 110 | } 111 | 112 | export async function local_path_save(device_id: string, file_path: string, content: string) { 113 | await callPost(url_local_path(device_id, file_path), content, true); 114 | } 115 | 116 | export async function local_path_rename(device_id: string, path: string, newName: string) { 117 | await callPost(url_local_path(device_id, path, `rename_to/${fixPath(newName)}`), "", true); 118 | } 119 | 120 | export async function local_path_toggleEnabled(device_id: string, path: string) { 121 | await callPost(url_local_path(device_id, path, "toggle_enabled"), "", true); 122 | } 123 | 124 | export async function local_path_delete(device_id: string, path: string) { 125 | await callDelete(url_local_path(device_id, path)); 126 | } 127 | 128 | export async function local_path_get(device_id: string, path: string) { 129 | return await callGet_text(url_local_path(device_id, path)); 130 | } 131 | 132 | export async function local_path_compiled(device_id: string, path: string) { 133 | return await callGet_text(url_local_path(device_id, path, "compiled")); 134 | } 135 | 136 | export async function local_path_testData_get(device_id: string, path: string) { 137 | return await callGet_text(url_local_path(device_id, path, "test-data")); 138 | } 139 | 140 | export async function local_path_testData_post(device_id: string, path: string, content: string) { 141 | return await callPost(url_local_path(device_id, path, "test-data"), content); 142 | } 143 | 144 | export async function esphome_device(device_id: string) { 145 | return await callGet_text(url_device(device_id, "esphome")); 146 | } 147 | 148 | export async function device_delete(device_id: string) { 149 | await callDelete(url_device(device_id)); 150 | } 151 | 152 | export async function getStatus() { 153 | return await callGet_json("/api/status"); 154 | } 155 | 156 | export async function getPing() { 157 | return await callGet_json("/api/device/ping"); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/app/utils/const.ts: -------------------------------------------------------------------------------- 1 | export const color_local = "Crimson"; 2 | export const color_esphome = "#18BCF2"; 3 | 4 | export const color_gray = "#656D76"; 5 | export const color_online = color_esphome; 6 | export const color_offline = color_local; -------------------------------------------------------------------------------- /src/app/utils/file-utils.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import { FileCodeIcon, FileDirectoryIcon, QuestionIcon, FileIcon as OFileIcon, FileAddedIcon, MarkdownIcon } from "@primer/octicons-react"; 3 | import etajsIcon from "@/assets/etajs-logo.svg"; 4 | import { TLocalFile, TLocalFileOrDirectory } from "@/server/devices/types"; 5 | import { esphomeLanguageId } from '../components/editors/monaco/languages'; 6 | 7 | export const FileIcon = (props: { fod: TLocalFileOrDirectory }) => { 8 | const { fod } = props; 9 | if ((fod == null) || (fod.type === "directory")) 10 | return 11 | switch (fod.language) { 12 | case "etajs": 13 | return etajs template; 14 | case "esphome": 15 | return ; 16 | case "patch": 17 | return ; 18 | case "plaintext": 19 | return ; 20 | case "markdown": 21 | return ; 22 | default: 23 | return 24 | } 25 | } 26 | 27 | export const getSourceMonacoLanguge = (file: TLocalFile) => { 28 | if (!file) return "text"; 29 | switch (file.language) { 30 | case "plaintext": 31 | return "text"; 32 | case "esphome": 33 | return esphomeLanguageId; 34 | case "patch": 35 | return "yaml"; 36 | case "etajs": 37 | return esphomeLanguageId; 38 | case "markdown": 39 | return "markdown"; 40 | default: 41 | throw new Error(`Unknown source language ${file.language}`); 42 | } 43 | } 44 | 45 | export const getTargetMonacoLanguage = (file: TLocalFile) => { 46 | if (!file) return "text"; 47 | switch (file.language) { 48 | case "plaintext": 49 | return "text"; 50 | case "esphome": 51 | return esphomeLanguageId; 52 | case "etajs": 53 | return esphomeLanguageId; 54 | case "markdown": 55 | return "html"; 56 | default: 57 | throw new Error(`Unknown target language ${file.language}`); 58 | } 59 | } -------------------------------------------------------------------------------- /src/app/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect, useState } from "react"; 3 | 4 | export const useDarkTheme = () => { 5 | const [isDarkMode, setIsDarkMode] = useState(false); 6 | 7 | useEffect(() => { 8 | const handleThemeChange = (e: MediaQueryListEvent) => setIsDarkMode(e.matches); 9 | 10 | const darkModeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 11 | 12 | setIsDarkMode(darkModeMediaQuery.matches) 13 | 14 | darkModeMediaQuery.addEventListener("change", handleThemeChange); 15 | return () => darkModeMediaQuery.removeEventListener("change", handleThemeChange); 16 | }, []); 17 | 18 | return isDarkMode; 19 | } -------------------------------------------------------------------------------- /src/assets/etajs-logo.svg: -------------------------------------------------------------------------------- 1 | ηη -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/onboarding/map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Morcatko/EspHome-Editor/e39ff003c71ea6d8d7a21670eb7b20d3d95f7800/src/assets/onboarding/map.png -------------------------------------------------------------------------------- /src/instrumentation.server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import { directoryExists } from "./server/utils/fs-utils"; 3 | import { log } from "./shared/log"; 4 | import { c, initConfig } from './server/config'; 5 | 6 | export async function init() { 7 | log.info("Initializing..."); 8 | 9 | await initConfig(); 10 | 11 | if (!await directoryExists(c.devicesDir)) { 12 | log.info('Creating devices directory'); 13 | await fs.mkdir(c.devicesDir); 14 | } 15 | if (!await directoryExists(c.devicesDir + "/.lib")) 16 | { 17 | log.info('Creating .lib directory'); 18 | await fs.mkdir(c.devicesDir + "/.lib"); 19 | } 20 | 21 | log.info("Config:", { 22 | devicesDir: c.devicesDir, 23 | espHomeApiUrl: c.espHomeApiUrl, 24 | espHomeWebUrl: c.espHomeWebUrl, 25 | version: c.version, 26 | }); 27 | 28 | log.success("Initialization complete"); 29 | } 30 | -------------------------------------------------------------------------------- /src/instrumentation.ts: -------------------------------------------------------------------------------- 1 | export async function register() { 2 | if (process.env.NEXT_RUNTIME === 'nodejs') { 3 | (await import("./instrumentation.server")).init(); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import type { NextRequest } from 'next/server' 3 | 4 | //Middleware to fix wrong url of codicon font (Monaco-editor) 5 | export function middleware(request: NextRequest) { 6 | const pathName = request.nextUrl.pathname.replace('/_next/static/css/_next/static/', '/_next/static/'); 7 | const newUrl = new URL(pathName, request.url) 8 | return NextResponse.rewrite(newUrl); 9 | } 10 | 11 | export const config = { 12 | matcher: '/_next/static/css/_next/static/:path*', 13 | } -------------------------------------------------------------------------------- /src/server/config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { getEspHomeUrls } from "./utils/ha-client"; 3 | 4 | const cwd = process.cwd() + "/"; 5 | const optimize = path.normalize; 6 | 7 | export const initConfig = async () => { 8 | const ENV_WORKFOLDER = optimize(cwd + (process.env.WORK_FOLDER || "/work-folder/")); 9 | const ENV_ESPHOME_URL = process.env.ESPHOME_URL?.replace(/\/+$/, ""); 10 | const ENV_HA_URL = process.env.HA_URL?.replace(/\/+$/, "") ?? "http://supervisor"; 11 | const ENV_SUPERVISOR_TOKEN = process.env.SUPERVISOR_TOKEN; 12 | 13 | const mode = ENV_SUPERVISOR_TOKEN ? "ha_addon" : "standalone"; 14 | 15 | process.env.E4E_VERSION = (await import("../../package.json")).version; 16 | process.env.E4E_DEVICES_DIR = optimize(ENV_WORKFOLDER + "/devices"); 17 | process.env.E4E_MODE = mode; 18 | if (mode === "ha_addon") { 19 | const urls = await getEspHomeUrls(ENV_HA_URL, ENV_SUPERVISOR_TOKEN!); 20 | process.env.E4E_ESPHOME_API_URL = urls?.apiUrl; 21 | process.env.E4E_ESPHOME_WEB_URL = urls?.webUrl; 22 | 23 | } else { 24 | process.env.E4E_ESPHOME_API_URL = ENV_ESPHOME_URL ?? ""; 25 | process.env.E4E_ESPHOME_WEB_URL = ENV_ESPHOME_URL ?? ""; 26 | } 27 | }; 28 | 29 | export const c = { 30 | get espHomeApiUrl() { 31 | return process.env.E4E_ESPHOME_API_URL || ""; 32 | }, 33 | get espHomeWebUrl() { 34 | return process.env.E4E_ESPHOME_WEB_URL || ""; 35 | }, 36 | get devicesDir() { 37 | return process.env.E4E_DEVICES_DIR || ""; 38 | }, 39 | get mode(): "ha_addon" | "standalone" | "unknown" { 40 | return (process.env.E4E_MODE || "unknown") as any; 41 | }, 42 | get version() { 43 | return process.env.E4E_VERSION || "unknown"; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/server/devices/esphome/client.ts: -------------------------------------------------------------------------------- 1 | import { c } from "@/server/config"; 2 | 3 | export type StreamEvent = { 4 | event: "line" | "exit"; 5 | data: string; 6 | code: number; 7 | }; 8 | 9 | export const esphome_stream = ( 10 | path: string, 11 | spawnParams: Record, 12 | onEvent: (event: StreamEvent) => void, 13 | onClose: (code: number) => void, 14 | onError: (data: any) => void, 15 | ): WebSocket => { 16 | const url = new URL(`${c.espHomeApiUrl}/${path}`); 17 | url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; 18 | const socket = new WebSocket(url.toString()); 19 | 20 | socket.addEventListener("message", (message) => { 21 | const event = JSON.parse(message.data); 22 | 23 | if (event.event === "line") { 24 | onEvent(event); 25 | return; 26 | } 27 | 28 | if (event.event === "exit") { 29 | onClose(event.code); 30 | } 31 | 32 | onError(message.data); 33 | }); 34 | 35 | socket.addEventListener("open", () => { 36 | socket.send( 37 | JSON.stringify({ 38 | type: "spawn", 39 | ...spawnParams, 40 | }), 41 | ); 42 | }); 43 | 44 | socket.addEventListener("close", () => { 45 | onError(new Error("Unexpected socket closure")); 46 | }); 47 | 48 | return socket; 49 | }; 50 | -------------------------------------------------------------------------------- /src/server/devices/esphome/index.ts: -------------------------------------------------------------------------------- 1 | import { c } from "@/server/config"; 2 | import type { TDevice } from "../types"; 3 | import { esphome_stream, type StreamEvent } from "./client"; 4 | import { log } from "@/shared/log"; 5 | import { assertResponseAndJsonOk, assertResponseOk } from "@/shared/http-utils"; 6 | 7 | type TEspHomeDevice = { 8 | name: string; 9 | friendly_name: string; 10 | configuration: string; 11 | }; 12 | 13 | type TEspHomeDevicesResponse = { 14 | configured: TEspHomeDevice[]; 15 | importable: any; 16 | }; 17 | 18 | export namespace espHome { 19 | export const tryGetDevices = async (): Promise => { 20 | const url = `${c.espHomeApiUrl}/devices` 21 | log.debug("Getting ESPHome devices", c.espHomeApiUrl ? url : "skipping - no url"); 22 | 23 | if (!c.espHomeApiUrl) 24 | return []; 25 | 26 | try { 27 | const devicesResponse = await assertResponseAndJsonOk(await fetch(url)) 28 | 29 | return devicesResponse 30 | .configured 31 | .map((d) => ({ 32 | id: d.name, 33 | name: d.name, 34 | files: null, 35 | type: "device", 36 | esphome_config: d.configuration, 37 | })); 38 | } 39 | catch (e) { 40 | log.error("Failed to get ESPHome devices", e); 41 | return []; 42 | } 43 | }; 44 | 45 | const tryGetDevice = async (device_id: string) => 46 | (await tryGetDevices()).find((d) => d.id === device_id); 47 | 48 | const getDevice = async (device_id: string) => { 49 | const device = await tryGetDevice(device_id); 50 | if (!device || !device.esphome_config) { 51 | throw new Error(`ESPHome Device not found: ${device_id}`); 52 | } 53 | return device; 54 | } 55 | 56 | export const getConfiguration = async (device_id: string) => { 57 | const device = await getDevice(device_id); 58 | const url = `${c.espHomeApiUrl}/edit?configuration=${device.esphome_config}`; 59 | log.debug("Getting ESPHome configuration", url); 60 | const response = await fetch(url); 61 | assertResponseOk(response); 62 | return await response.text(); 63 | }; 64 | 65 | export const saveConfiguration = async (device_id: string, content: string) => { 66 | let device = await tryGetDevice(device_id); 67 | 68 | if ((!device || !device.esphome_config)) { 69 | log.info("Device not found in ESPHome, creating", device_id); 70 | //Create device in ESPHome 71 | await fetch(`${c.espHomeApiUrl}/wizard`, { 72 | method: "POST", 73 | body: JSON.stringify({ 74 | ssid: "!secret wifi_ssid", 75 | psk: "!secret wifi_password", 76 | name: device_id, 77 | board: "esp32-s3-devkitc-1" 78 | }) 79 | }); 80 | device = await getDevice(device_id.toLowerCase()); 81 | } 82 | 83 | //Create device if it does not exist??? 84 | const url = `${c.espHomeApiUrl}/edit?configuration=${device.esphome_config}`; 85 | log.debug("Saving ESPHome configuration", url); 86 | const response = await fetch(url, { 87 | method: "POST", 88 | body: content, 89 | }); 90 | assertResponseOk(response); 91 | } 92 | 93 | export const deleteDevice = async (device_id: string) => { 94 | let device = await tryGetDevice(device_id); 95 | 96 | if ((!device || !device.esphome_config)) { 97 | log.warn("Device not found in ESPHome, cannot delete", device_id); 98 | return; 99 | } 100 | 101 | const url = `${c.espHomeApiUrl}/delete?configuration=${device.esphome_config}` 102 | log.debug("Deleting ESPHome device", url); 103 | const response = await fetch(url, { 104 | method: "POST", 105 | }); 106 | assertResponseOk(response); 107 | } 108 | 109 | export const getPing = async () => { 110 | if (!c.espHomeApiUrl) 111 | return null; 112 | 113 | const url = `${c.espHomeApiUrl}/ping`; 114 | //log.debug("Pinging ESPHome", url); 115 | const response = await fetch(url); 116 | return await assertResponseAndJsonOk(response); 117 | } 118 | 119 | export const stream = async ( 120 | device_id: string, 121 | path: string, 122 | spawnParams: Record | null, 123 | onEvent: (event: StreamEvent) => void, 124 | onClose: (code: number) => void, 125 | onError: (data: any) => void, 126 | ) => { 127 | const device = await getDevice(device_id); 128 | return esphome_stream( 129 | path, 130 | { ...spawnParams, configuration: device.esphome_config }, 131 | onEvent, 132 | onClose, 133 | onError); 134 | } 135 | } -------------------------------------------------------------------------------- /src/server/devices/index.ts: -------------------------------------------------------------------------------- 1 | import { espHome } from "./esphome"; 2 | import { local } from "./local"; 3 | 4 | export const getTreeData = async () => { 5 | const esphome_data = await espHome.tryGetDevices(); 6 | const local_data = await local.getDevices(); 7 | 8 | const esphome_map = new Map(esphome_data.map((d) => [d.name, d])); 9 | const local_map = new Map(local_data.map((d) => [d.name, d])); 10 | 11 | const all_names = Array.from( 12 | new Set( 13 | esphome_data.map((d) => d.name) 14 | .concat(local_data.map((d) => d.name)), 15 | ), 16 | ); 17 | 18 | const data = all_names 19 | .map((n) => { 20 | const esphome_device = esphome_map.get(n); 21 | const local_device = local_map.get(n); 22 | return { 23 | ...esphome_device, 24 | ...local_device, 25 | }; 26 | }) 27 | .sort((a, b) => a.name!.localeCompare(b.name!)); 28 | 29 | return data; 30 | }; 31 | 32 | export const importEspHomeToLocalDevice = async (device_id: string) => { 33 | await local.createDevice(device_id); 34 | const configuration = await espHome.getConfiguration(device_id); 35 | await local.saveFileContent(device_id, "configuration.yaml", configuration); 36 | } 37 | 38 | export const deleteDevice = async (device_id: string) => { 39 | await local.deleteDevice(device_id); 40 | await espHome.deleteDevice(device_id); 41 | } -------------------------------------------------------------------------------- /src/server/devices/local/compiler.ts: -------------------------------------------------------------------------------- 1 | import { log } from "@/shared/log"; 2 | import { getDeviceDir } from "./utils"; 3 | import { compileFile, getFileInfo } from "./template-processors"; 4 | import { listDirEntries } from "@/server/utils/fs-utils"; 5 | import { tryMergeEspHomeYamlFiles } from "./template-processors/yaml-merger"; 6 | import { tryPatchEspHomeYaml } from "./template-processors/yaml-patcher"; 7 | import { TOperationResult } from "../types"; 8 | import { ManifestUtils } from "./manifest-utils"; 9 | 10 | export const compileDevice = async (device_id: string) => { 11 | log.debug("Compiling device", device_id); 12 | 13 | const result: TOperationResult = { 14 | success: false, 15 | value: "", 16 | logs: [], 17 | }; 18 | 19 | const deviceDirectory = getDeviceDir(device_id); 20 | 21 | const fileEntries = await listDirEntries(deviceDirectory, (e) => e.isFile()); 22 | const inputFiles = fileEntries 23 | .map(f => ({ 24 | path: f.name, 25 | info: getFileInfo(f.name) 26 | })); 27 | 28 | const compiledYamls: TFileContent[] = []; 29 | for (const file of inputFiles.filter(i => i.info.type === "basic")) { 30 | const isFileDisabled = await ManifestUtils.isPathDisabled(device_id, file.path); 31 | if (isFileDisabled) { 32 | log.debug("Skipping disabled file", file.path); 33 | continue; 34 | } 35 | 36 | try { 37 | compiledYamls.push({ 38 | path: file.path, 39 | value: await compileFile(device_id, file.path, false), 40 | }); 41 | 42 | result.logs.push({ 43 | type: "info", 44 | path: file.path, 45 | message: "Compiling file", 46 | }); 47 | } catch (e) { 48 | result.logs.push({ 49 | type: "error", 50 | path: file.path, 51 | message: `Error compiling file - ${e?.toString() ?? "no more info"}`, 52 | }); 53 | return result; 54 | } 55 | } 56 | 57 | log.debug("Merging compiled configurations", device_id); 58 | const mergeResult = tryMergeEspHomeYamlFiles(compiledYamls); 59 | result.logs.push(...mergeResult.logs); 60 | if (!mergeResult.success) 61 | return result; 62 | log.success("Merged compiled configurations", device_id); 63 | 64 | const compiledPatches: TFileContent[] = []; 65 | for (const file of inputFiles.filter(i => i.info.type === "patch")) { 66 | const isFileDisabled = await ManifestUtils.isPathDisabled(device_id, file.path); 67 | if (isFileDisabled) { 68 | log.debug("Skipping disabled file", file.path); 69 | continue; 70 | } 71 | 72 | try { 73 | compiledPatches.push({ 74 | path: file.path, 75 | value: await compileFile(device_id, file.path, false), 76 | }); 77 | 78 | result.logs.push({ 79 | type: "info", 80 | path: file.path, 81 | message: "Compiling patch file", 82 | }); 83 | } 84 | catch (e) { 85 | result.logs.push({ 86 | type: "error", 87 | path: file.path, 88 | message: `Error compiling patch file - ${e?.toString() ?? "no more info"}` 89 | }); 90 | return result; 91 | } 92 | } 93 | 94 | log.debug("Patching configuration", device_id); 95 | const patchResult = tryPatchEspHomeYaml(mergeResult.value, compiledPatches); 96 | result.logs.push(...patchResult.logs); 97 | if (!patchResult.success) 98 | return result; 99 | log.success("Patched configuration", device_id); 100 | 101 | result.value = patchResult.value.toString(); 102 | result.success = true; 103 | return result; 104 | } -------------------------------------------------------------------------------- /src/server/devices/local/files.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import { dirname, join } from "node:path"; 3 | import { log } from "@/shared/log"; 4 | import { c } from "@/server/config"; 5 | import { directoryExists, fileExists, listDirEntries } from "@/server/utils/fs-utils"; 6 | import type { TDevice } from "../types"; 7 | import { awaitArray, ensureDeviceDirExists, fixPath, getDeviceDir, getDevicePath, scanDirectory } from "./utils"; 8 | import { ManifestUtils } from "./manifest-utils"; 9 | 10 | export const getDevices = async (): Promise => { 11 | log.debug("Getting Local devices"); 12 | const deviceDirectories = await listDirEntries( 13 | c.devicesDir, 14 | (d) => d.isDirectory(), 15 | ); 16 | 17 | const resAsync = deviceDirectories 18 | .map(async (d) => { 19 | return { 20 | id: d.name, 21 | path: "", 22 | name: d.name, 23 | type: "device", 24 | files: await scanDirectory(d.name, `${c.devicesDir}/${d.name}`, null), 25 | } as TDevice; 26 | }); 27 | 28 | return awaitArray(resAsync); 29 | } 30 | 31 | export const createDirectory = async (device_id: string, path: string) => { 32 | await ensureDeviceDirExists(device_id); 33 | const fullPath = getDevicePath(device_id, path); 34 | await fs.mkdir(fullPath); 35 | } 36 | 37 | export const createFile = async (device_id: string, path: string) => { 38 | await saveFileContent(device_id, path, ""); 39 | } 40 | 41 | export const getFileContent = async (device_id: string, file_path: string) => { 42 | const path = getDevicePath(device_id, file_path); 43 | return await fs.readFile(path, "utf-8"); 44 | } 45 | 46 | export const tryGetFileContent = async (device_id: string, file_path: string) => { 47 | const path = getDevicePath(device_id, file_path); 48 | return await fileExists(path) ? await fs.readFile(path, "utf-8") : null; 49 | } 50 | 51 | export const saveFileContent = async (device_id: string, file_path: string, content: string) => { 52 | await ensureDeviceDirExists(device_id); 53 | const path = getDevicePath(device_id, file_path); 54 | await fs.writeFile(path, content, "utf-8"); 55 | } 56 | 57 | export const createDevice = async (device_id: string) => 58 | await fs.mkdir(getDeviceDir(device_id)); 59 | 60 | export const renameFile = async (device_id: string, path: string, newName: string) => { 61 | const oldPath = getDevicePath(device_id, path); 62 | const parentDir = dirname(oldPath); 63 | const newPath = join(parentDir, fixPath(newName)); 64 | log.debug(`Renaming file '${oldPath}' to '${newName}'`); 65 | await fs.rename(oldPath, newPath); 66 | await ManifestUtils.renameFile(device_id, path, newName); 67 | }; 68 | 69 | export const deletePath = async (device_id: string, path: string) => { 70 | const fullPath = getDevicePath(device_id, path); 71 | log.debug(`Deleting file '${fullPath}'`); 72 | const stats = await fs.stat(fullPath); 73 | if (stats.isDirectory()) 74 | await fs.rmdir(fullPath) 75 | else { 76 | await fs.unlink(fullPath); 77 | await ManifestUtils.deleteFile(device_id, path); 78 | } 79 | } 80 | 81 | export const deleteDevice = async (device_id: string) => { 82 | const fullPath = getDevicePath(device_id, ""); 83 | if (await directoryExists(fullPath)) { 84 | log.debug(`Deleting device '${fullPath}'`); 85 | await fs.rm(fullPath, { recursive: true }); 86 | } 87 | } -------------------------------------------------------------------------------- /src/server/devices/local/index.ts: -------------------------------------------------------------------------------- 1 | import { compileDevice } from "./compiler"; 2 | import { compileFile } from "./template-processors"; 3 | import { createDevice, createDirectory, createFile, deleteDevice, deletePath, getDevices, getFileContent, renameFile, saveFileContent, tryGetFileContent } from "./files"; 4 | import { ManifestUtils } from "./manifest-utils"; 5 | 6 | export const local = { 7 | getDevices, 8 | createDirectory, 9 | createFile, 10 | getFileContent, 11 | tryGetFileContent, 12 | saveFileContent, 13 | createDevice, 14 | togglePathEnabled : ManifestUtils.togglePathEnabled, 15 | renameFile, 16 | deletePath, 17 | deleteDevice, 18 | compileFile, 19 | compileDevice, 20 | } -------------------------------------------------------------------------------- /src/server/devices/local/manifest-utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import { fixPath, getDevicePath } from "./utils"; 3 | import { fileExists } from "@/server/utils/fs-utils"; 4 | 5 | type TManifestFileInfo = { 6 | disabled?: boolean; 7 | } 8 | 9 | type TManifest = { 10 | files: { [path: string]: TManifestFileInfo}; 11 | }; 12 | 13 | const manifestFileName = "manifest.json"; 14 | 15 | //Do not load for each file, load once and use the loaded manifest 16 | async function loadManifest(device_id: string): Promise { 17 | const manifestPath = getDevicePath(device_id, manifestFileName); 18 | if (await fileExists(manifestPath)) 19 | return JSON.parse(await fs.readFile(manifestPath, "utf-8")) as TManifest; 20 | return { files: {} }; 21 | } 22 | 23 | async function saveManifest(device_id: string, manifest: TManifest) { 24 | const manifestPath = getDevicePath(device_id, manifestFileName); 25 | await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"); 26 | } 27 | 28 | async function renameFile(device_id: string, old_path: string, new_path: string) { 29 | const manifest = await loadManifest(device_id); 30 | old_path = fixPath(old_path); 31 | new_path = fixPath(new_path); 32 | if (manifest.files[old_path]) { 33 | manifest.files[new_path] = manifest.files[old_path]; 34 | delete manifest.files[old_path]; 35 | } 36 | await saveManifest(device_id, manifest); 37 | } 38 | 39 | async function deleteFile(device_id: string, path: string) { 40 | const manifest = await loadManifest(device_id); 41 | path = fixPath(path); 42 | if (manifest.files[path]) { 43 | delete manifest.files[path]; 44 | } 45 | await saveManifest(device_id, manifest); 46 | } 47 | 48 | async function togglePathEnabled(device_id: string, path: string) { 49 | const manifest = await loadManifest(device_id); 50 | path = fixPath(path); 51 | if (!manifest.files[path]) 52 | manifest.files[path] = { }; 53 | 54 | manifest.files[path].disabled = manifest.files[path].disabled ? false : true; 55 | 56 | await saveManifest(device_id, manifest); 57 | } 58 | 59 | async function isPathDisabled(device_id: string, path: string) { 60 | const manifest = await loadManifest(device_id); 61 | path = fixPath(path); 62 | return !!(manifest.files[path]?.disabled); 63 | } 64 | 65 | export const ManifestUtils = { 66 | manifestFileName, 67 | renameFile, 68 | deleteFile, 69 | togglePathEnabled, 70 | isPathDisabled 71 | } -------------------------------------------------------------------------------- /src/server/devices/local/template-processors/eta.ts: -------------------------------------------------------------------------------- 1 | import { c } from "@/server/config"; 2 | import { Eta } from "eta" 3 | 4 | 5 | export const processTemplate_eta = (filePath: string, testData: string | null) => { 6 | const eta = new Eta({ 7 | views: c.devicesDir, 8 | cache: true, 9 | debug: false, 10 | rmWhitespace: false, 11 | autoTrim: false }) 12 | 13 | const output = eta.render( 14 | filePath, 15 | JSON.parse(testData || "{}") 16 | //{ filepath: './devices/plc/index.eta'} 17 | ); 18 | 19 | return output; 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/server/devices/local/template-processors/index.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { marked } from "marked"; 3 | import { processTemplate_eta } from "./eta"; 4 | import { fixPath, getDevicePath } from "../utils"; 5 | import { fileExists } from "@/server/utils/fs-utils"; 6 | 7 | 8 | export type TLanguge = "plaintext" | "esphome" | "patch" | "etajs" | "markdown"; 9 | 10 | type FileInfo = { 11 | type: "basic" | "patch" | "none", 12 | language: TLanguge; 13 | }; 14 | 15 | 16 | export const getFileInfo = (file_path: string): FileInfo => { 17 | const lower = file_path.toLowerCase(); 18 | if (lower.endsWith(".patch.yaml")) { 19 | return { 20 | type: "patch", 21 | language: "patch" 22 | }; 23 | } else if (lower.endsWith(".yaml")) { 24 | return { 25 | type: "basic", 26 | language: "esphome" 27 | }; 28 | } else if (lower.endsWith(".patch.eta")) { 29 | return { 30 | type: "patch", 31 | language: "etajs" 32 | }; 33 | } else if (lower.endsWith(".eta")) { 34 | return { 35 | type: "basic", 36 | language: "etajs" 37 | }; 38 | } else if (lower.endsWith(".txt")) { 39 | return { 40 | type: "none", 41 | language: "plaintext" 42 | } 43 | } else if (lower.endsWith(".md")) { 44 | return { 45 | type: "none", 46 | language: "markdown" 47 | } 48 | } else { 49 | return { 50 | type: "none", 51 | language: "plaintext" 52 | } 53 | } 54 | } 55 | 56 | export const compileFile = async (device_id: string, file_path: string, useTestData: boolean) => { 57 | const fullFilePath = getDevicePath(device_id, file_path); 58 | const fixedFilePath = fixPath(file_path); 59 | const fileInfo = getFileInfo(file_path); 60 | switch (fileInfo.language) { 61 | case "etajs": 62 | const testDataPath = getDevicePath(device_id, file_path + ".testdata"); 63 | const testData = useTestData && await fileExists(testDataPath) 64 | ? await readFile(testDataPath, 'utf-8') 65 | : null; 66 | return processTemplate_eta(device_id + "/" + fixedFilePath, testData); 67 | case "patch": 68 | case "esphome": 69 | case "plaintext": 70 | return readFile(fullFilePath, 'utf-8'); 71 | case "markdown": 72 | const md = await readFile(fullFilePath, 'utf-8'); 73 | return marked.parse(md); 74 | default: 75 | throw new Error(`Unsupported language:${fileInfo.language}`); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /src/server/devices/local/template-processors/types.ts: -------------------------------------------------------------------------------- 1 | 2 | type TFileContent = { 3 | path: string; 4 | value: string; 5 | } -------------------------------------------------------------------------------- /src/server/devices/local/template-processors/yaml-merger.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { tryMergeEspHomeYamlFiles } from "./yaml-merger"; 3 | import { yamlParse } from "@/server/utils/yaml-utils"; 4 | 5 | const _testMerge = (expectedYaml: string, ...yamls: string[]) => { 6 | const expected = yamlParse(expectedYaml); 7 | const result = tryMergeEspHomeYamlFiles(yamls.map(y => ({path: "x", value: y}))); 8 | expect(result.success).toBeTruthy(); 9 | expect(result.value.toString()).toEqual(expected.toString()); 10 | } 11 | 12 | test("merge different keys", () => { 13 | _testMerge(` 14 | test1: abc 15 | test2: def 16 | test3: ghi`, 17 | `test1: abc`, 18 | `test2: def`, 19 | `test3: ghi`); 20 | }) 21 | 22 | test("merge same keys - seq", () => { 23 | _testMerge(` 24 | buttons: 25 | - id: b_1 26 | name: "button 1" 27 | - id: b_2 28 | name: "button 2" 29 | - id: b_a 30 | name: "button a"`, 31 | ` 32 | buttons: 33 | - id: b_1 34 | name: "button 1" 35 | - id: b_2 36 | name: "button 2"`, ` 37 | buttons: 38 | - id: b_a 39 | name: "button a"`); 40 | }); 41 | 42 | test("merge same keys - map", () => { 43 | _testMerge(` 44 | substitutions: 45 | name: "device 1" 46 | id: device_1`,` 47 | substitutions: 48 | name: "device 1"`, ` 49 | substitutions: 50 | id: device_1`); 51 | }); 52 | 53 | test ("merge - empty", () => { 54 | _testMerge(` 55 | buttons: 56 | - id: b_a 57 | name: "button a"`, 58 | ``,` 59 | buttons: 60 | - id: b_a 61 | name: "button a"`); 62 | }); 63 | 64 | test("merge same keys - first empty - does not work", () => { 65 | _testMerge(` 66 | buttons: 67 | - id: b_a 68 | name: "button a"`, 69 | ` 70 | buttons:`, ` 71 | buttons: 72 | - id: b_a 73 | name: "button a"`); 74 | }); 75 | 76 | test("merge same keys - second empty", () => { 77 | _testMerge(` 78 | buttons: 79 | - id: b_a 80 | name: "button a"`, 81 | ` 82 | buttons: 83 | - id: b_a 84 | name: "button a"`, 85 | `buttons:`); 86 | }); 87 | 88 | test("bigInts (Issue #101)", () => { 89 | const doc = ` 90 | sensor: 91 | - platform: dallas_temp 92 | address: 0x323cc1e38143aa28`; 93 | 94 | _testMerge(doc, doc); 95 | }); -------------------------------------------------------------------------------- /src/server/devices/local/template-processors/yaml-merger.ts: -------------------------------------------------------------------------------- 1 | import { yamlParse } from "@/server/utils/yaml-utils"; 2 | import * as YAML from "yaml"; 3 | import { isMap, isScalar, isSeq } from "yaml"; 4 | import { TOperationResult } from "../../types"; 5 | 6 | const mergeEspHomeYamls = (target: YAML.Document, source: YAML.Document) => { 7 | if (!source.contents) { 8 | return; 9 | } 10 | 11 | if (!target.contents) { 12 | target.contents = new YAML.YAMLMap, unknown>(); 13 | } 14 | const tgtContent = target.contents as YAML.YAMLMap< 15 | YAML.Scalar, 16 | unknown 17 | >; 18 | 19 | const srcContent = source.contents as YAML.YAMLMap< 20 | YAML.Scalar, 21 | unknown 22 | >; 23 | const tgtItems = tgtContent.items; 24 | 25 | srcContent.items.forEach((srcItem) => { 26 | const key = srcItem.key.value; 27 | const tgtItem = tgtItems.find((i) => i.key.value === key); 28 | if (!tgtItem) { 29 | tgtItems.push(srcItem); 30 | } else { 31 | if (isScalar(srcItem.value) && (srcItem.value as YAML.Scalar).value === null) { 32 | return; 33 | } 34 | 35 | if (isMap(tgtItem.value) && isMap(srcItem.value)) { 36 | const tgtMap = tgtItem.value as YAML.YAMLMap; 37 | const srcMap = srcItem.value as YAML.YAMLMap; 38 | srcMap.items.forEach((srcMapItem) => tgtMap.add(srcMapItem)); 39 | return; 40 | 41 | } 42 | if (isScalar(tgtItem.value) && (tgtItem.value.value === null)) { 43 | tgtItem.value = srcItem.value; 44 | return; 45 | } 46 | 47 | if (!isSeq(srcItem.value)) { 48 | throw new Error(`Unsupported merge - '${srcItem.value}'`); 49 | } 50 | if (!isSeq(tgtItem.value)) { 51 | throw new Error(`Unsupported merge - '${srcItem.value}'`); 52 | } 53 | 54 | const tgtValues = tgtItem.value as YAML.YAMLSeq; 55 | const srcValues = srcItem.value as YAML.YAMLSeq; 56 | srcValues.items.forEach((srcValue) => 57 | tgtValues.items.push(srcValue) 58 | ); 59 | } 60 | }); 61 | }; 62 | 63 | export const tryMergeEspHomeYamlFiles = (yamls: TFileContent[]) => { 64 | const result: TOperationResult> = { 65 | success: false, 66 | value: new YAML.Document(), 67 | logs: [], 68 | }; 69 | 70 | try { 71 | for (const yaml of yamls) { 72 | try { 73 | const yamlContent = yamlParse(yaml.value); 74 | mergeEspHomeYamls(result.value, yamlContent); 75 | result.logs.push({ 76 | type: "info", 77 | message: `Merging file`, 78 | path: yaml.path, 79 | }); 80 | } catch (e) { 81 | result.logs.push({ 82 | type: "error", 83 | message: `Error merging file - ${e?.toString() ?? "no more info"}`, 84 | path: yaml.path 85 | }); 86 | return result; 87 | } 88 | } 89 | } catch (e) { 90 | result.logs.push({ 91 | type: "error", 92 | message: `Error merging files - ${e?.toString() ?? "no more info"}`, 93 | path: "", 94 | }); 95 | return result; 96 | } 97 | 98 | result.success = true; 99 | return result; 100 | }; 101 | -------------------------------------------------------------------------------- /src/server/devices/local/template-processors/yaml-patcher.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import * as YAML from "yaml"; 3 | import { test_patchYaml } from "./yaml-patcher"; 4 | import { yamlParse } from "@/server/utils/yaml-utils"; 5 | 6 | const testDoc = yamlParse(` 7 | sensor: 8 | - id: s1 9 | - id: s2`); 10 | 11 | 12 | const patchTestDoc = (path: string, changesYaml: string) => test_patchYaml( 13 | testDoc, 14 | path, 15 | (yamlParse(changesYaml).contents as YAML.YAMLSeq).items as YAML.YAMLMap[]); 16 | 17 | test("yamlPatch - set-map", () => { 18 | const changesDoc = ` 19 | - set: 20 | name: "set by map" 21 | x: by-map`; 22 | 23 | const res = patchTestDoc("$.sensor[?(@.id==\"s1\")]", changesDoc); 24 | 25 | const s1 = (res.get("sensor") as YAML.YAMLSeq).get(0) as YAML.YAMLMap; 26 | expect(s1.get("name")).toEqual("set by map"); 27 | expect(s1.get("x")).toEqual("by-map"); 28 | }); 29 | 30 | test("yamlPatch - set-seq", () => { 31 | const changesDoc = ` 32 | - set: 33 | - name: "set by seq" 34 | - x: by-seq`; 35 | 36 | const res = patchTestDoc("$.sensor[?(@.id==\"s1\")]", changesDoc); 37 | 38 | const s1 = (res.get("sensor") as YAML.YAMLSeq).get(0) as YAML.YAMLMap; 39 | expect(s1.get("name")).toEqual("set by seq"); 40 | expect(s1.get("x")).toEqual("by-seq"); 41 | }); -------------------------------------------------------------------------------- /src/server/devices/local/template-processors/yaml-patcher.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "@/server/yamlpath"; 2 | import * as YAML from "yaml"; 3 | import { isMap, isSeq } from "yaml"; 4 | import { TOperationResult } from "../../types"; 5 | 6 | const patchYaml = (target: YAML.Document, path: string, changes: YAML.YAMLMap[]) => { 7 | const nodesToChange = parse(target, path); 8 | 9 | for (const change of changes) { 10 | const pair = change.items[0] as YAML.Pair; 11 | const operation = pair.key.value; 12 | 13 | const values = isSeq(pair.value) 14 | ? (pair.value as YAML.YAMLSeq).items.flatMap(item => (item as YAML.YAMLMap).items) 15 | : (pair.value as YAML.YAMLMap).items; 16 | 17 | for (const nodeToChange of nodesToChange) { 18 | if (isMap(nodeToChange)) { 19 | if (operation === "add") { 20 | for (const value of values) { 21 | nodeToChange.add(value, true); 22 | } 23 | } else if (operation === "set") { 24 | for (const value of values) { 25 | nodeToChange.add(value, true); 26 | } 27 | } else { 28 | throw new Error("Unsupported operation"); 29 | } 30 | } else { 31 | throw new Error("Unsupported node type"); 32 | } 33 | } 34 | /* 35 | const key = change.items[0].key.toString(); 36 | const value = change.items[0].value; 37 | if (isMap(nodeToChange)) { 38 | const pair = new YAML.Pair(new YAML.Scalar(key), value); 39 | nodeToChange.add(pair, true); 40 | } else { 41 | throw new Error("Unsupported node type"); 42 | }*/ 43 | } 44 | }; 45 | 46 | export const test_patchYaml = (target: YAML.Document, path: string, changes: YAML.YAMLMap[]) => { 47 | patchYaml(target, path, changes); 48 | return target; 49 | } 50 | 51 | export const tryPatchEspHomeYaml = (target: YAML.Document, patches: TFileContent[]) => { 52 | const result: TOperationResult> = { 53 | success: false, 54 | value: target, 55 | logs: [], 56 | }; 57 | 58 | try { 59 | for (const patchJob of patches) { 60 | try { 61 | const patch = YAML.parseDocument(patchJob.value, { intAsBigInt: true }); 62 | const contents = patch.contents; 63 | 64 | if (!(isSeq(contents))) { 65 | throw new Error("Document root must be YAMLSeq"); 66 | } else { 67 | for (const item of contents.items) { 68 | if (!(isMap(item))) { 69 | throw new Error("Item must be YAMLMap"); 70 | } else { 71 | const patch = item.items[0]; 72 | if ((YAML.isPair(patch)) 73 | && (isSeq(patch.value))) { 74 | const path = patch.key.toString(); 75 | const patches = patch.value.items as YAML.YAMLMap[]; 76 | patchYaml(target, path, patches); 77 | } 78 | else { 79 | throw new Error("Invalid patch format"); 80 | } 81 | } 82 | } 83 | } 84 | 85 | result.logs.push({ 86 | type: "info", 87 | message: `Patch applied successfully`, 88 | path: patchJob.path 89 | }); 90 | } catch (e) { 91 | result.logs.push({ 92 | type: "error", 93 | message: `Error applying patch - ${e?.toString() ?? "no more info"}`, 94 | path: patchJob.path 95 | }); 96 | return result; 97 | } 98 | } 99 | } catch (e) { 100 | result.logs.push({ 101 | type: "error", 102 | message: `Error patching YAML - ${e?.toString() ?? "no more info"}`, 103 | path: "", 104 | }); 105 | return result; 106 | } 107 | 108 | result.success = true; 109 | return result; 110 | }; -------------------------------------------------------------------------------- /src/server/devices/local/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import { c } from "@/server/config"; 4 | import { directoryExists, listDirEntries } from "@/server/utils/fs-utils"; 5 | import type { TLocalDirectory, TLocalFile, TLocalFileOrDirectory } from "../types"; 6 | import { getFileInfo } from "./template-processors"; 7 | import { ManifestUtils } from "./manifest-utils"; 8 | 9 | export const getDeviceDir = (device_id: string) => 10 | join(c.devicesDir, device_id); 11 | 12 | export const fixPath = (path: string) => 13 | path.replaceAll("\\", "/"); 14 | 15 | export const getDevicePath = (device_id: string, path: string) => 16 | join(getDeviceDir(device_id), fixPath(path)); 17 | 18 | export const ensureDeviceDirExists = async (device_id: string) => { 19 | const path = getDeviceDir(device_id); 20 | if (!(await directoryExists(path))) 21 | await fs.mkdir(path); 22 | } 23 | 24 | export const awaitArray = async (arr: Promise[]): Promise => 25 | (await Promise 26 | .allSettled(arr)) 27 | .map((r) => r.status === "fulfilled" ? r.value : null) 28 | .filter((r) => r !== null) 29 | .map((r) => r as T); 30 | 31 | export const scanDirectory = async (device_id: string, fullPath: string, parentPath: string | null): Promise => { 32 | const resAsync = 33 | (await listDirEntries(fullPath, _ => true)) 34 | .map(async (e) => { 35 | const path = parentPath ? `${parentPath}/${e.name}` : e.name; 36 | if (e.isDirectory()) { 37 | return { 38 | id: e.name, 39 | name: e.name, 40 | path: path, 41 | type: "directory", 42 | files: await scanDirectory(device_id, `${fullPath}/${e.name}`, path), 43 | }; 44 | } else { 45 | if (e.name.endsWith(".testdata") || (e.name == ManifestUtils.manifestFileName)) 46 | return null; 47 | 48 | return { 49 | id: e.name, 50 | path: path, 51 | name: e.name, 52 | disabled: await ManifestUtils.isPathDisabled(device_id, path), 53 | language: getFileInfo(`${fullPath}/${e.name}`).language, 54 | type: "file", 55 | }; 56 | } 57 | }); 58 | 59 | return (await awaitArray(resAsync)) 60 | .filter((e) => e !== null) 61 | .sort((a, b) => a.type.localeCompare(b.type)); 62 | }; -------------------------------------------------------------------------------- /src/server/devices/types.ts: -------------------------------------------------------------------------------- 1 | import { type TLanguge,} from "./local/template-processors"; 2 | 3 | export type TNode = { 4 | id: string; 5 | path: string; 6 | name: string; 7 | } 8 | 9 | export type TParent = TNode &{ 10 | files: TLocalFileOrDirectory[] | null; 11 | }; 12 | 13 | 14 | export type TLocalDirectory = TParent & { 15 | type: "directory"; 16 | } 17 | 18 | export type TLocalFile = TNode & { 19 | type: "file"; 20 | language: TLanguge; 21 | disabled: boolean; 22 | } 23 | 24 | export type TLocalFileOrDirectory = TLocalDirectory | TLocalFile; 25 | 26 | 27 | export type TDevice = TParent & { 28 | type: "device" 29 | esphome_config: string; 30 | } 31 | 32 | type TLog = { 33 | type: "info" | "error", 34 | message: string, 35 | path: string, 36 | }; 37 | 38 | export type TOperationResult = { 39 | success: boolean; 40 | value: TValue; 41 | logs: TLog[]; 42 | } -------------------------------------------------------------------------------- /src/server/utils/fs-utils.ts: -------------------------------------------------------------------------------- 1 | import { type Dirent } from "node:fs"; 2 | import fs from "node:fs/promises"; 3 | 4 | export const directoryExists = async (path: string) => { 5 | try { 6 | await fs.access(path); 7 | return true; 8 | } catch { 9 | return false; 10 | } 11 | }; 12 | 13 | export const fileExists = directoryExists; 14 | 15 | export async function listDirEntries( 16 | path: string, 17 | predicate: (item: Dirent) => boolean | Promise, 18 | ) { 19 | const dirs = await fs.readdir(path, { withFileTypes: true }); 20 | return dirs.filter(predicate); 21 | } -------------------------------------------------------------------------------- /src/server/utils/ha-client.ts: -------------------------------------------------------------------------------- 1 | import { assertResponseAndJsonOk } from "@/shared/http-utils"; 2 | import { log } from "@/shared/log"; 3 | 4 | const ha_getJson = async (haUrl: string, haToken: string, path: string) => { 5 | const url = `${haUrl}/${path}`; 6 | log.debug("Fetching", url); 7 | const response = await fetch(url, 8 | { 9 | headers: { 10 | Authorization: `Bearer ${haToken}` 11 | } 12 | } 13 | ) 14 | return await assertResponseAndJsonOk(response); 15 | } 16 | 17 | const findEspHomeAddon = async (haUrl: string, haToken: string) => { 18 | const responseJson = await ha_getJson(haUrl, haToken, "addons"); 19 | const addons = responseJson.data.addons as any[]; 20 | const espHomeAddon = addons.find(a => a.name === "ESPHome Device Builder") 21 | || addons.find(a => a.name === "ESPHome Device Compiler") 22 | || addons.find(a => a.url === "https://esphome.io/"); 23 | return espHomeAddon; 24 | } 25 | 26 | // const getDiscovery = async (haUrl: string, haToken: string) => { 27 | // const responseJson = await ha_getJson(haUrl, haToken, "discovery"); 28 | // log.info("discovery", responseJson); 29 | // const discoveries = responseJson.data.discovery as any[]; 30 | // const espHomeDiscovery = discoveries.find(a => a.service === "esphome"); 31 | 32 | // const config = espHomeDiscovery.config; 33 | // return `http://${config.host}:${config.port}`; 34 | // } 35 | 36 | export const getEspHomeUrls = async (haUrl: string, haToken: string) => { 37 | log.debug("Getting ESPHome URL"); 38 | try { 39 | const espHomeAddon = await findEspHomeAddon(haUrl, haToken); 40 | const espHomeSlug = espHomeAddon.slug; 41 | 42 | const addon = await ha_getJson(haUrl, haToken, `addons/${espHomeSlug}/info`); 43 | const port = addon.data.ingress_port 44 | return { 45 | apiUrl: `http://localhost:${port}`, 46 | webUrl: `/${espHomeSlug}` 47 | }; 48 | } catch (e) { 49 | log.error("Error finding ESPHome addon", e); 50 | } 51 | return null; 52 | } -------------------------------------------------------------------------------- /src/server/utils/yaml-utils.ts: -------------------------------------------------------------------------------- 1 | import * as YAML from "yaml"; 2 | 3 | export const yamlParse = (yaml: string) => YAML.parseDocument(yaml, { intAsBigInt: true }); -------------------------------------------------------------------------------- /src/server/yamlpath/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as YAML from "yaml"; 2 | import { readFile } from "node:fs/promises"; 3 | import { expect, test } from 'vitest' 4 | import { parse } from '.'; 5 | 6 | const loadYaml = async (name: string) => { 7 | const yamlString = await readFile("./src/server/yamlpath/" + name, "utf-8"); 8 | return YAML.parseDocument(yamlString); 9 | }; 10 | 11 | const yamlDocument = await loadYaml("test-sample1.yaml"); 12 | 13 | 14 | const _test = (path: string) => { 15 | const result = parse( 16 | yamlDocument, 17 | path); 18 | /*console.log("final result: ", result); 19 | console.log(".............."); 20 | console.log(new Date());*/ 21 | return result; 22 | } 23 | 24 | test("simple path", async () => { 25 | const actual = _test("$.esphome.name"); 26 | expect(actual[0]).toBe("plc-01"); 27 | }) 28 | 29 | test("simple path - multiple", async () => { 30 | const actual = _test("$.binary_sensor.id"); 31 | expect(actual).toStrictEqual(["mbc_0x01_input_0x01", "mbc_0x01_input_0x02", "mbc_0x01_input_0x03"]); 32 | }) 33 | 34 | test("attribute equals string", async () => { 35 | const actual = await _test("$.binary_sensor[?(@.id=='mbc_0x01_input_0x02')]"); 36 | expect(actual.length).toBe(1); 37 | expect((actual[0] as YAML.YAMLMap).items.length).toBe(3); 38 | expect((actual[0] as YAML.YAMLMap).get("id")).toBe("mbc_0x01_input_0x02"); 39 | }); -------------------------------------------------------------------------------- /src/server/yamlpath/index.ts: -------------------------------------------------------------------------------- 1 | import * as YAML from "yaml"; 2 | 3 | import { parse as jp_parse } from "jsonpathly"; 4 | import { 5 | BracketExpressionContent, 6 | DotContent, 7 | FilterExpressionContent, 8 | Root, 9 | Subscript, 10 | } from "jsonpathly/dist/parser/types"; 11 | import { isMap, isSeq } from "yaml"; 12 | 13 | 14 | //import { log as logger } from "@/shared/log"; 15 | const log = (..._args: any[]) => { }/*logger.create({defaults: { 16 | level: 950, 17 | //type: "verbose", 18 | } 19 | }).log;*/ 20 | 21 | const _applyDot = (nodes: YAML.Node[], path: DotContent): YAML.Node[] => { 22 | log("_applyDot: ", path); 23 | switch (path.type) { 24 | case "identifier": 25 | return nodes 26 | .flatMap((n) => { 27 | if (isMap(n)) { 28 | return [n.get(path.value) as YAML.Node]; 29 | } else if (isSeq(n)) { 30 | return _applyDot(n.items as YAML.Node[], path); 31 | } 32 | else { 33 | throw new Error("Unsupported node type: ", n?.toJSON()); 34 | } 35 | }) 36 | .filter((n) => n); 37 | default: 38 | throw new Error("Unsupported dot content type: " + path.type); 39 | } 40 | }; 41 | 42 | const _applyFilterExpression = (nodes: YAML.Node[], path: FilterExpressionContent) => { 43 | log("_applyFilterExpression: ", path); 44 | switch (path.type) { 45 | case "comparator": 46 | if (path.left.type !== "current") { 47 | throw new Error("Unsupported left type: " + path.left.type); 48 | } 49 | if (path.right?.type !== "value") { 50 | throw new Error("Unsupported right type: " + path.right?.type); 51 | } 52 | 53 | if (path.left.next?.type !== "subscript") { 54 | throw new Error("Unsupported left next type: " + path.left.next?.type); 55 | } 56 | 57 | const leftOperand = path.left.next; 58 | const rightValue = path.right.value; 59 | 60 | 61 | if (nodes.length > 1) 62 | throw new Error("Multiple nodes not supported in filter expression"); 63 | 64 | const _nodesToFilter = (isMap(nodes[0])) 65 | ? nodes 66 | : (nodes[0] as YAML.YAMLSeq).items as YAML.Node[]; 67 | 68 | return _nodesToFilter.filter((n) => { 69 | const res = _applySubscript([n], leftOperand); 70 | 71 | if (res.length === 0) 72 | return false; 73 | else if (res.length === 1) 74 | return (res[0] as any) === rightValue; 75 | else 76 | throw new Error("Multiple results in filter expression"); 77 | 78 | }); 79 | default: 80 | throw new Error("Unsupported filter expression type: " + path.type); 81 | } 82 | }; 83 | 84 | const _applyBracketExpression = ( 85 | nodes: YAML.Node[], 86 | path: BracketExpressionContent, 87 | ) => { 88 | log("_applyBracketExpression: ", path); 89 | switch (path.type) { 90 | case "filterExpression": 91 | return _applyFilterExpression(nodes, path.value); 92 | default: 93 | throw new Error( 94 | "Unsupported bracket expression content type: " + path.type, 95 | ); 96 | } 97 | }; 98 | 99 | const _applySubscript = (nodes: YAML.Node[], path: Subscript): YAML.Node[] => { 100 | log("_applySubscript: ", path); 101 | let result: YAML.Node[] = []; 102 | 103 | switch (path.value.type) { 104 | case "dot": 105 | result = _applyDot(nodes, path.value.value); 106 | break; 107 | case "bracketExpression": 108 | result = _applyBracketExpression(nodes, path.value.value); 109 | break; 110 | 111 | default: 112 | throw new Error("Unsupported subscript type: " + path.value.type); 113 | } 114 | 115 | return (path.next) ? _applySubscript(result, path.next) : result; 116 | }; 117 | 118 | const apply = (yaml: YAML.Document, root: Root | null) => { 119 | if ((root?.type === "root") && yaml.contents) { 120 | if (root.next) { 121 | return _applySubscript([yaml.contents], root.next); 122 | } 123 | } 124 | throw new Error("Unsupported root type: " + root?.type); 125 | }; 126 | 127 | export const parse = (yamlDocument: YAML.Document, path: string) => { 128 | log("Path: ", path); 129 | const jp_path = jp_parse(path); 130 | return apply(yamlDocument, jp_path); 131 | }; 132 | -------------------------------------------------------------------------------- /src/server/yamlpath/test-sample1.yaml: -------------------------------------------------------------------------------- 1 | esphome: 2 | name: plc-01 3 | friendly_name: PLC-01 4 | platformio_options: 5 | board_build.flash_mode: dio 6 | 7 | esp32: 8 | board: esp32-s3-devkitc-1 9 | framework: 10 | type: arduino 11 | 12 | logger: 13 | api: 14 | ota: 15 | - platform: esphome 16 | 17 | wifi: 18 | ssid: !secret wifi_ssid 19 | password: !secret wifi_password 20 | 21 | captive_portal: 22 | uart: 23 | - id: uart_485 24 | baud_rate: 115200 25 | tx_pin: 16 26 | rx_pin: 15 27 | 28 | modbus: 29 | - id: mb_main 30 | uart_id: uart_485 31 | 32 | modbus_controller: 33 | - id: mbc_0x01 34 | address: 0x1 35 | modbus_id: mb_main 36 | update_interval: 50ms 37 | setup_priority: -10 38 | 39 | binary_sensor: 40 | - platform: modbus_controller 41 | modbus_controller_id: mbc_0x01 42 | id: mbc_0x01_input_0x01 43 | - platform: modbus_controller 44 | modbus_controller_id: mbc_0x01 45 | id: mbc_0x01_input_0x02 46 | - platform: modbus_controller 47 | modbus_controller_id: mbc_0x01 48 | id: mbc_0x01_input_0x03 -------------------------------------------------------------------------------- /src/shared/http-utils.ts: -------------------------------------------------------------------------------- 1 | import { log } from "./log"; 2 | 3 | export const assertResponseOk = async (response: Response) => { 4 | if (!response.ok) { 5 | let textContent = ""; 6 | try { 7 | textContent = await response.text(); 8 | } catch(e) { 9 | log.error("Failed to read response", e); 10 | } 11 | 12 | log.info("Call failed", response.url, response.status, textContent); 13 | throw new Error(`Failed to call ${response.url} ${response.status} ${textContent}`); 14 | } 15 | } 16 | 17 | export const assertResponseAndJsonOk = async (response: Response) => { 18 | await assertResponseOk(response); 19 | try { 20 | return (await response.json() as T); 21 | } catch (e) { 22 | log.error("Failed to parse json", e); 23 | throw new Error(`Failed to parse json ${e}`); 24 | } 25 | } -------------------------------------------------------------------------------- /src/shared/log.ts: -------------------------------------------------------------------------------- 1 | import { consola } from "consola"; 2 | 3 | export const log = consola; 4 | log.level = 999; -------------------------------------------------------------------------------- /tasks.mts: -------------------------------------------------------------------------------- 1 | import { exit, env } from "node:process"; 2 | import *as fs from "node:fs/promises"; 3 | import { execaCommand } from "execa"; 4 | import { confirm, input, select, Separator } from "@inquirer/prompts"; 5 | import YAML from 'yaml' 6 | 7 | const image_name_prod = "morcatko/esphome-editor"; 8 | const image_name_dev = "morcatko/esphome-editor-dev"; 9 | const addon_path = env.TASKS_ADDON_PATH; 10 | 11 | const exec = (command: string) => { 12 | console.log(`Command: ${command}`); 13 | const promise = execaCommand(command, { 14 | stdout: "inherit", 15 | stdin: "pipe", 16 | stderr: "inherit", 17 | }); 18 | promise.finally(() => { 19 | console.log("\n"); 20 | }); 21 | return promise; 22 | }; 23 | 24 | const getVersion = async () => (await import("./package.json")).version; 25 | 26 | const dockerBuild = async ( 27 | image_name: string, 28 | tags: string[], 29 | platforms: string[], 30 | ) => { 31 | 32 | console.log("========================================="); 33 | console.log(`Building\n ${image_name}\ntags:\n ${tags.join("\n ")}\nplatforms:\n -${platforms.join("\n -")}`); 34 | console.log("========================================="); 35 | await exec(`docker buildx build --platform=${platforms.join(",")} --load ${tags.map(t => `-t ${image_name}:${t}`).join(" ")} -f dockerfile .`); 36 | if (await confirm({ message: `Push ${image_name}:(${tags.join(", ")})`})) { 37 | for (const tag of tags) { 38 | await exec(`docker push ${image_name}:${tag}`); 39 | } 40 | } 41 | } 42 | 43 | const createNewVersion = async () => { 44 | const version = await input({ message: "Enter version" }); 45 | await exec(`npm pkg set version=${version}`); 46 | 47 | console.log("Updating config.yaml version"); 48 | const yaml = YAML.parse(await fs.readFile(addon_path + '/config.yaml', 'utf8'), { keepSourceTokens: true }); 49 | yaml["version"] = version 50 | await fs.writeFile(addon_path + '/config.yaml', YAML.stringify(yaml, { keepSourceTokens: true })); 51 | } 52 | 53 | 54 | const mainLoop = async () => { 55 | const version = await getVersion(); 56 | const answer = await select({ 57 | message: "Select a task", 58 | choices: [ 59 | { 60 | name: `Docker Dev - Build (${image_name_dev}:latest)`, 61 | value: "docker_dev_build", 62 | }, 63 | { 64 | name: `Docker Dev - Run (${image_name_dev}:latest)`, 65 | value: "docker_dev_run", 66 | }, 67 | new Separator(), 68 | { 69 | name: `Create New Version`, 70 | value: "create_new_version", 71 | }, 72 | { 73 | name: `Docker Prod - Build (${image_name_prod}:${version})`, 74 | value: "docker_prod_build", 75 | }, 76 | ], 77 | }); 78 | 79 | switch (answer) { 80 | case "docker_dev_build": 81 | await dockerBuild(image_name_dev, ["latest"], ["linux/amd64"]); 82 | break; 83 | case "docker_dev_run": 84 | await exec(`docker run --rm -p 8080:3000 -e ESPHOME_URL=http://192.168.0.15:6052/ ${image_name_dev}:latest`); 85 | break; 86 | case "create_new_version": 87 | await createNewVersion(); 88 | break; 89 | case "docker_prod_build": 90 | //https://github.com/home-assistant/supervisor/blob/main/supervisor/data/arch.json 91 | //"raspberrypi4-64": ["aarch64", "armv7", "armhf"], 92 | // MAP_ARCH in https://github.com/home-assistant/supervisor/blob/main/supervisor/docker/interface.py 93 | // MAP_ARCH = { 94 | // CpuArch.ARMV7: "linux/arm/v7", 95 | // CpuArch.ARMHF: "linux/arm/v6", 96 | // CpuArch.AARCH64: "linux/arm64", 97 | // CpuArch.I386: "linux/386", 98 | // CpuArch.AMD64: "linux/amd64", 99 | // } 100 | const platforms = [ 101 | "linux/amd64", 102 | "linux/arm64", 103 | "linux/arm/v6", 104 | "linux/arm/v7", 105 | ] 106 | const tags = [ 107 | "latest", 108 | await getVersion() 109 | ]; 110 | await dockerBuild(image_name_prod, tags, platforms); 111 | break; 112 | } 113 | }; 114 | 115 | await mainLoop(); 116 | exit(0); 117 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2024", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ], 29 | "@3rd-party/*": [ 30 | "./src/3rd-party/*" 31 | ] 32 | } 33 | }, 34 | "include": [ 35 | "next-env.d.ts", 36 | "./src/**/*.ts", 37 | "./src/**/*.tsx", 38 | ".next/types/**/*.ts" 39 | ], 40 | "exclude": [ 41 | "node_modules" 42 | ] 43 | //"exclude": ["node_modules"] 44 | } 45 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import path from "path"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | include: ["src/**/*.test.ts"], 7 | }, 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "src"), 11 | "@3rd-party": path.resolve(__dirname, "3rd-party") 12 | }, 13 | } 14 | }) --------------------------------------------------------------------------------