API: generate /api/state documentation from UI types (#32431)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
andig 2026-08-03 13:06:37 +02:00 • committed by GitHub
parent c7c744f54e
commit c3f3384bfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 8757 additions and 3347 deletions

View file

@ -176,6 +176,9 @@ jobs:
- name: Install - name: Install
run: make install-ui run: make install-ui
- name: OpenAPI
run: make openapi
- name: Lint - name: Lint
run: make lint-ui run: make lint-ui

View file

@ -58,7 +58,7 @@ jobs:
- name: Prepare OpenAPI spec - name: Prepare OpenAPI spec
run: | run: |
mkdir -p ./openapi-deploy mkdir -p ./openapi-deploy
cp ./server/openapi.yaml ./openapi-deploy/openapi.yaml npx --yes @redocly/cli@2 bundle ./server/openapi.yaml -o ./openapi-deploy/openapi.yaml
- name: Deploy OpenAPI spec to docs repo - name: Deploy OpenAPI spec to docs repo
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4 uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4

View file

@ -8,6 +8,7 @@ on:
pull_request: pull_request:
paths: paths:
- "server/openapi.yaml" - "server/openapi.yaml"
- "server/openapi.state.yaml"
- ".github/workflows/openapi-validate.yml" - ".github/workflows/openapi-validate.yml"
jobs: jobs:
@ -22,4 +23,4 @@ jobs:
with: with:
go-version: stable go-version: stable
- name: Validate OpenAPI spec - name: Validate OpenAPI spec
run: go run github.com/getkin/kin-openapi/cmd/validate@v0.133.0 -- server/openapi.yaml run: go run github.com/getkin/kin-openapi/cmd/validate@v0.133.0 -ext -- server/openapi.yaml

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ testdata/**/*
!assets/js/**/*.yaml !assets/js/**/*.yaml
!package*.json !package*.json
!evcc.dist.yaml !evcc.dist.yaml
!server/openapi.state.yaml
!.coderabbit.yaml !.coderabbit.yaml
!tests/**/*.evcc.yaml !tests/**/*.evcc.yaml
!tests/**/*.tpl.yaml !tests/**/*.tpl.yaml

View file

@ -48,7 +48,6 @@ RUN --mount=type=cache,target=${GOMODCACHE} go mod download
# install tools # install tools
COPY Makefile . COPY Makefile .
COPY cmd/implement/ cmd/implement/ COPY cmd/implement/ cmd/implement/
COPY cmd/openapi/ cmd/openapi/
COPY api/ api/ COPY api/ api/
RUN --mount=type=cache,target=${GOMODCACHE} make install RUN --mount=type=cache,target=${GOMODCACHE} make install

View file

@ -31,7 +31,7 @@ CURRDIR := $(shell pwd)
default:: ui build default:: ui build
all:: clean install install-ui ui assets lint test-ui lint-ui test build all:: clean install install-ui ui assets openapi lint test-ui lint-ui test build
clean:: clean::
rm -rf dist/ rm -rf dist/
@ -48,6 +48,9 @@ ui::
assets:: assets::
go generate ./... go generate ./...
openapi::
vp run openapi
docs:: docs::
go generate github.com/evcc-io/evcc/util/templates/... go generate github.com/evcc-io/evcc/util/templates/...

View file

@ -38,9 +38,7 @@ import formatter from "@/mixins/formatter";
import minuteTicker from "@/mixins/minuteTicker"; import minuteTicker from "@/mixins/minuteTicker";
import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts"; import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { CURRENCY, Vehicle } from "@/types/evcc"; import type { CURRENCY, Forecast, PlanStrategy, Vehicle } from "@/types/evcc";
import type { PlanStrategy } from "./types";
import type { Forecast } from "@/types/evcc.ts";
export default defineComponent({ export default defineComponent({
name: "ChargingPlan", name: "ChargingPlan",

View file

@ -47,13 +47,17 @@ import GenericModal from "../Helper/GenericModal.vue";
import PlansSettings from "./PlansSettings.vue"; import PlansSettings from "./PlansSettings.vue";
import api from "@/api"; import api from "@/api";
import type { import type {
CURRENCY,
Forecast,
PlanStrategy, PlanStrategy,
RepeatingPlan, RepeatingPlan,
SMART_COST_TYPE,
StaticEnergyPlan, StaticEnergyPlan,
StaticPlan, StaticPlan,
StaticSocPlan, StaticSocPlan,
} from "./types"; UiLoadpoint,
import type { CURRENCY, Forecast, SMART_COST_TYPE, UiLoadpoint, Vehicle } from "@/types/evcc"; Vehicle,
} from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "ChargingPlanModal", name: "ChargingPlanModal",

View file

@ -64,7 +64,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import type { PlanStrategy } from "./types"; import type { PlanStrategy } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "ChargingPlanStrategy", name: "ChargingPlanStrategy",

View file

@ -32,7 +32,7 @@ import PlanRepeatingSettings from "./PlanRepeatingSettings.vue";
import deepEqual from "@/utils/deepEqual"; import deepEqual from "@/utils/deepEqual";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { RepeatingPlan } from "./types"; import type { RepeatingPlan } from "@/types/evcc";
const DEFAULT_WEEKDAYS = [1, 2, 3, 4, 5]; const DEFAULT_WEEKDAYS = [1, 2, 3, 4, 5];
const DEFAULT_TARGET_TIME = "07:00"; const DEFAULT_TARGET_TIME = "07:00";

View file

@ -81,16 +81,18 @@ import api from "@/api";
import deepEqual from "@/utils/deepEqual"; import deepEqual from "@/utils/deepEqual";
import { debounceLeading } from "@/utils/debounceLeading"; import { debounceLeading } from "@/utils/debounceLeading";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { Vehicle, CURRENCY, Forecast } from "@/types/evcc";
import type { import type {
StaticPlan, CURRENCY,
RepeatingPlan, Forecast,
PlanWrapper,
StaticSocPlan,
StaticEnergyPlan,
PlanResponse, PlanResponse,
PlanStrategy, PlanStrategy,
} from "./types"; PlanWrapper,
RepeatingPlan,
StaticEnergyPlan,
StaticPlan,
StaticSocPlan,
Vehicle,
} from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "ChargingPlansSettings", name: "ChargingPlansSettings",

View file

@ -21,8 +21,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import type { PlanWrapper } from "./types"; import type { PlanWrapper, Tariff } from "@/types/evcc";
import type { Tariff } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "ChargingPlanWarnings", name: "ChargingPlanWarnings",

View file

@ -1,39 +0,0 @@
import type { Rate } from "@/types/evcc";
export interface RepeatingPlan {
weekdays: number[];
time: string;
tz: string; // timezone like "Europe/Berlin"
soc: number;
active: boolean;
}
export interface PlanWrapper {
planId: number;
planTime: Date;
duration: number;
plan: Rate[] | null;
power: number;
}
export interface PlanResponse {
status: number;
data: PlanWrapper;
}
export type StaticPlan = StaticSocPlan | StaticEnergyPlan;
export interface StaticSocPlan {
soc: number;
time: Date;
}
export interface StaticEnergyPlan {
energy: number;
time: Date;
}
export interface PlanStrategy {
continuous: boolean;
precondition: number;
}

View file

@ -42,9 +42,8 @@ import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
import { registerChartComponents, commonOptions } from "../Sessions/chartConfig"; import { registerChartComponents, commonOptions } from "../Sessions/chartConfig";
import formatter, { POWER_UNIT } from "@/mixins/formatter"; import formatter, { POWER_UNIT } from "@/mixins/formatter";
import colors, { lighterColor } from "@/colors"; import colors, { lighterColor } from "@/colors";
import type { CURRENCY } from "@/types/evcc"; import type { CURRENCY, ForecastSlot, SolarDetails, TimeseriesEntry } from "@/types/evcc";
import { ForecastType, highestSlotIndexByDay } from "@/utils/forecast"; import { ForecastType, highestSlotIndexByDay } from "@/utils/forecast";
import type { ForecastSlot, SolarDetails, TimeseriesEntry } from "./types";
registerChartComponents([ registerChartComponents([
BarController, BarController,

View file

@ -50,8 +50,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import type { CURRENCY } from "@/types/evcc"; import type { CURRENCY, ForecastSlot } from "@/types/evcc";
import type { ForecastSlot } from "./types";
import { isStaticTariff } from "@/utils/forecast"; import { isStaticTariff } from "@/utils/forecast";
const MAX_HOURS = 96; const MAX_HOURS = 96;

View file

@ -23,8 +23,7 @@ import {
import colors, { lighterColor } from "@/colors"; import colors, { lighterColor } from "@/colors";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import chartMixin from "./chartMixin"; import chartMixin from "./chartMixin";
import type { CURRENCY } from "@/types/evcc"; import type { CURRENCY, ForecastSlot } from "@/types/evcc";
import type { ForecastSlot } from "./types";
export default defineComponent({ export default defineComponent({
name: "PriceChart", name: "PriceChart",

View file

@ -19,7 +19,7 @@ import colors, { lighterColor } from "@/colors";
import formatter, { POWER_UNIT } from "@/mixins/formatter"; import formatter, { POWER_UNIT } from "@/mixins/formatter";
import chartMixin from "./chartMixin"; import chartMixin from "./chartMixin";
import { highestSlotIndexByDay } from "@/utils/forecast"; import { highestSlotIndexByDay } from "@/utils/forecast";
import type { SolarDetails, TimeseriesEntry } from "./types"; import type { SolarDetails, TimeseriesEntry } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "SolarChart", name: "SolarChart",

View file

@ -19,7 +19,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter"; import formatter, { POWER_UNIT } from "@/mixins/formatter";
import type { SolarDetails } from "./types"; import type { SolarDetails } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "SolarDetails", name: "SolarDetails",

View file

@ -22,7 +22,7 @@ import {
import colors from "@/colors"; import colors from "@/colors";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import chartMixin from "./chartMixin"; import chartMixin from "./chartMixin";
import type { ForecastSlot } from "./types"; import type { ForecastSlot } from "@/types/evcc";
export type ValueChartType = "co2" | "temperature"; export type ValueChartType = "co2" | "temperature";

View file

@ -20,7 +20,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import type { ForecastSlot } from "./types"; import type { ForecastSlot } from "@/types/evcc";
import type { ValueChartType } from "./ValueChart.vue"; import type { ValueChartType } from "./ValueChart.vue";
const MAX_HOURS = 96; const MAX_HOURS = 96;

View file

@ -1,7 +1,7 @@
import * as echarts from "echarts/core"; import * as echarts from "echarts/core";
import colors from "@/colors"; import colors from "@/colors";
import escapeHtml from "@/utils/escapeHtml"; import escapeHtml from "@/utils/escapeHtml";
import type { ForecastSlot } from "./types"; import type { ForecastSlot } from "@/types/evcc";
import { BarChart, LineChart } from "echarts/charts"; import { BarChart, LineChart } from "echarts/charts";
import { import {
GridComponent, GridComponent,

View file

@ -1,27 +0,0 @@
export function isForecastSlot(obj?: TimeseriesEntry | ForecastSlot): obj is ForecastSlot {
return (obj as ForecastSlot).start !== undefined;
}
export interface TimeseriesEntry {
val: number;
ts: string;
}
export interface ForecastSlot {
start: string;
end: string;
value: number;
}
export interface EnergyByDay {
energy: number;
complete: boolean;
}
export interface SolarDetails {
scale?: number;
today?: EnergyByDay;
tomorrow?: EnergyByDay;
dayAfterTomorrow?: EnergyByDay;
timeseries?: TimeseriesEntry[];
}

View file

@ -129,9 +129,9 @@ import type {
SMART_COST_TYPE, SMART_COST_TYPE,
BATTERY_MODE, BATTERY_MODE,
LoadpointUi, LoadpointUi,
PlanStrategy,
LoadpointSuggestion, LoadpointSuggestion,
} from "@/types/evcc"; } from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
export default defineComponent({ export default defineComponent({
name: "Loadpoint", name: "Loadpoint",

View file

@ -24,8 +24,7 @@ import { defineComponent, type PropType } from "vue";
import api from "@/api"; import api from "@/api";
import { setLoadpointLastSmartCostLimit } from "@/uiLoadpoints"; import { setLoadpointLastSmartCostLimit } from "@/uiLoadpoints";
import settings from "@/settings"; import settings from "@/settings";
import { type CURRENCY, SMART_COST_TYPE } from "@/types/evcc"; import { type CURRENCY, type ForecastSlot, SMART_COST_TYPE } from "@/types/evcc";
import { type ForecastSlot } from "../Forecast/types";
export default defineComponent({ export default defineComponent({
name: "SmartCostLimit", name: "SmartCostLimit",

View file

@ -105,7 +105,7 @@ export default defineComponent({
}, },
vehicleOptions(): SelectOption<string>[] { vehicleOptions(): SelectOption<string>[] {
return this.vehicles.map((v) => ({ return this.vehicles.map((v) => ({
name: v.name, name: v.name ?? "",
value: v.title, value: v.title,
})); }));
}, },

View file

@ -99,9 +99,9 @@ import {
type VehicleStatus, type VehicleStatus,
type Vehicle, type Vehicle,
type LoadpointUi, type LoadpointUi,
type PlanStrategy,
type LoadpointSuggestion, type LoadpointSuggestion,
} from "@/types/evcc"; } from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
import BatteryBoostButton from "../Loadpoints/BatteryBoostButton.vue"; import BatteryBoostButton from "../Loadpoints/BatteryBoostButton.vue";
import type ChargingPlanModal from "../ChargingPlans/ChargingPlanModal.vue"; import type ChargingPlanModal from "../ChargingPlans/ChargingPlanModal.vue";

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import type { ForecastSlot, TimeseriesEntry, SolarDetails } from "../components/Forecast/types"; import type { ForecastSlot, TimeseriesEntry, SolarDetails } from "@/types/evcc";
import deepCopy from "./deepClone"; import deepCopy from "./deepClone";
export enum ForecastType { export enum ForecastType {

View file

@ -1,28 +0,0 @@
package main
import (
"encoding/json"
"log"
"os"
"github.com/getkin/kin-openapi/openapi3"
)
func main() {
doc, err := openapi3.NewLoader().LoadFromFile(os.Args[1])
if err != nil {
log.Fatal("failed to load OpenAPI spec:", err)
}
// omit servers
doc.Servers = nil
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile(os.Args[2], b, 0o644); err != nil {
log.Fatal(err)
}
}

1
go.mod
View file

@ -258,7 +258,6 @@ require (
tool ( tool (
github.com/dmarkham/enumer github.com/dmarkham/enumer
github.com/evcc-io/evcc/cmd/implement github.com/evcc-io/evcc/cmd/implement
github.com/evcc-io/evcc/cmd/openapi
github.com/evcc-io/openapi-mcp/cmd/openapi-mcp github.com/evcc-io/openapi-mcp/cmd/openapi-mcp
github.com/gokrazy/tools/cmd/gok github.com/gokrazy/tools/cmd/gok
go.uber.org/mock/mockgen go.uber.org/mock/mockgen

392
package-lock.json generated
View file

@ -36,6 +36,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0", "@playwright/test": "^1.62.0",
"@redocly/openapi-core": "^2.38.0",
"@storybook/vue3": "^10.4.4", "@storybook/vue3": "^10.4.4",
"@storybook/vue3-vite": "^10.5.5", "@storybook/vue3-vite": "^10.5.5",
"@types/body-parser": "^1.19.6", "@types/body-parser": "^1.19.6",
@ -51,6 +52,8 @@
"@vue/compiler-sfc": "^3.5.38", "@vue/compiler-sfc": "^3.5.38",
"@vue/test-utils": "^2.4.11", "@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"body-parser": "^2.3.0", "body-parser": "^2.3.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"happy-dom": "^20.11.1", "happy-dom": "^20.11.1",
@ -60,6 +63,7 @@
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"storybook": "^10.5.5", "storybook": "^10.5.5",
"terser": "^5.49.0", "terser": "^5.49.0",
"ts-json-schema-generator": "^2.9.0",
"tsx": "^4.23.1", "tsx": "^4.23.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.6", "vite": "npm:@voidzero-dev/vite-plus-core@0.2.6",
@ -68,7 +72,8 @@
"vue-hot-reload-api": "^2.3.4", "vue-hot-reload-api": "^2.3.4",
"vue-tsc": "^3.3.8", "vue-tsc": "^3.3.8",
"wait-on": "^9.1.0", "wait-on": "^9.1.0",
"ws": "^8.21.1" "ws": "^8.21.1",
"yaml": "^2.9.0"
}, },
"engines": { "engines": {
"node": ">=26.0.0", "node": ">=26.0.0",
@ -4032,6 +4037,98 @@
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/@redocly/ajv": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.18.3.tgz",
"integrity": "sha512-l42u0of3hY98sN2A+M4qTX1O/KrpgGH32Hu9kP2GtHyD5Dfqq86PKFLe5dwaD8DEnNmlOlll2BAmeEtf0DaySg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/config": {
"version": "0.53.1",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.53.1.tgz",
"integrity": "sha512-aOldTWynKS3ZM6u8WkL72d94QBobITv9X6UECiZykgVjdLiAqV737bPVLGKJYPWf0Ryf3zSHgUYALjCj66BkdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "2.7.2"
}
},
"node_modules/@redocly/openapi-core": {
"version": "2.43.2",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-2.43.2.tgz",
"integrity": "sha512-4Z1gCrw/uiNlC0V0IsoJ0CAn4BB8mlo7xgchDZARrWqf8nBh6BDTWICAJ8tFWJxachRvNhZkBhUQwBgIBOfiYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "^8.18.1",
"@redocly/config": "^0.53.1",
"ajv": "npm:@redocly/ajv@8.18.1",
"ajv-formats": "^3.0.1",
"colorette": "^1.2.0",
"graphql": "^16.14.1",
"js-levenshtein": "^1.1.6",
"js-yaml": "^5.2.2",
"picomatch": "^4.0.4",
"pluralize": "^8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=22.12.0 || >=20.19.0 <21.0.0",
"npm": ">=10"
}
},
"node_modules/@redocly/openapi-core/node_modules/ajv": {
"name": "@redocly/ajv",
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.18.1.tgz",
"integrity": "sha512-Ifm/pP/tul1qmAecpbVxCBluVE32rKfjf8gYXH4xI2gCv9mRWFhJMHzkPDM4TXlxwPQYIFegymlsy8lXz7optA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/openapi-core/node_modules/js-yaml": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz",
"integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
@ -4340,6 +4437,13 @@
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/kill-port": { "node_modules/@types/kill-port": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/kill-port/-/kill-port-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/kill-port/-/kill-port-2.0.3.tgz",
@ -5656,6 +5760,41 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/alien-signals": { "node_modules/alien-signals": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
@ -6376,6 +6515,13 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -7033,6 +7179,13 @@
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-unique-numbers": { "node_modules/fast-unique-numbers": {
"version": "9.0.27", "version": "9.0.27",
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
@ -7054,6 +7207,23 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -7271,6 +7441,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/graphql": {
"version": "16.14.2",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz",
"integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/happy-dom": { "node_modules/happy-dom": {
"version": "20.11.1", "version": "20.11.1",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz",
@ -7694,6 +7874,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-sdsl": { "node_modules/js-sdsl": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
@ -7761,6 +7951,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-to-ts": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-2.7.2.tgz",
"integrity": "sha512-R1JfqKqbBR4qE8UyBR56Ms30LL62/nlhoz+1UkfI/VE7p54Awu919FZ6ZUPG8zIa3XB65usPJgr1ONVncUGSaQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@types/json-schema": "^7.0.9",
"ts-algebra": "^1.2.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@ -8491,6 +8703,16 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
} }
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nostics": { "node_modules/nostics": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz", "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz",
@ -8982,6 +9204,16 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@ -9468,6 +9700,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": { "node_modules/require-main-filename": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@ -9732,6 +9974,16 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -10494,6 +10746,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/ts-algebra": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-1.2.2.tgz",
"integrity": "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==",
"dev": true,
"license": "MIT"
},
"node_modules/ts-dedent": { "node_modules/ts-dedent": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz",
@ -10504,6 +10763,130 @@
"node": ">=6.10" "node": ">=6.10"
} }
}, },
"node_modules/ts-json-schema-generator": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/ts-json-schema-generator/-/ts-json-schema-generator-2.9.0.tgz",
"integrity": "sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.15",
"commander": "^14.0.3",
"glob": "^13.0.6",
"json5": "^2.2.3",
"normalize-path": "^3.0.0",
"safe-stable-stringify": "^2.5.0",
"tslib": "^2.8.1",
"typescript": "^5.9.3"
},
"bin": {
"ts-json-schema-generator": "bin/ts-json-schema-generator.js"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/ts-json-schema-generator/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/ts-json-schema-generator/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/ts-json-schema-generator/node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/ts-json-schema-generator/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ts-json-schema-generator/node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/ts-json-schema-generator/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ts-json-schema-generator/node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ts-json-schema-generator/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/ts-map": { "node_modules/ts-map": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/ts-map/-/ts-map-1.0.3.tgz", "resolved": "https://registry.npmjs.org/ts-map/-/ts-map-1.0.3.tgz",
@ -11932,6 +12315,13 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "15.4.1", "version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",

View file

@ -11,6 +11,7 @@
"lint:tsc": "vue-tsc --noEmit", "lint:tsc": "vue-tsc --noEmit",
"lint:i18n": "tsx i18n/check.ts", "lint:i18n": "tsx i18n/check.ts",
"dev": "vp dev", "dev": "vp dev",
"openapi": "tsx scripts/state-schema/index.ts",
"playwright": "playwright install && playwright test --ui", "playwright": "playwright install && playwright test --ui",
"playwright:ci": "cross-env CI=true playwright test", "playwright:ci": "cross-env CI=true playwright test",
"simulator": "vp dev tests/simulator", "simulator": "vp dev tests/simulator",
@ -61,6 +62,7 @@
"repository": "github:evcc-io/evcc", "repository": "github:evcc-io/evcc",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0", "@playwright/test": "^1.62.0",
"@redocly/openapi-core": "^2.38.0",
"@storybook/vue3": "^10.4.4", "@storybook/vue3": "^10.4.4",
"@storybook/vue3-vite": "^10.5.5", "@storybook/vue3-vite": "^10.5.5",
"@types/body-parser": "^1.19.6", "@types/body-parser": "^1.19.6",
@ -76,6 +78,8 @@
"@vue/compiler-sfc": "^3.5.38", "@vue/compiler-sfc": "^3.5.38",
"@vue/test-utils": "^2.4.11", "@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"body-parser": "^2.3.0", "body-parser": "^2.3.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"happy-dom": "^20.11.1", "happy-dom": "^20.11.1",
@ -85,6 +89,7 @@
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"storybook": "^10.5.5", "storybook": "^10.5.5",
"terser": "^5.49.0", "terser": "^5.49.0",
"ts-json-schema-generator": "^2.9.0",
"tsx": "^4.23.1", "tsx": "^4.23.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.6", "vite": "npm:@voidzero-dev/vite-plus-core@0.2.6",
@ -93,6 +98,7 @@
"vue-tsc": "^3.3.8", "vue-tsc": "^3.3.8",
"wait-on": "^9.1.0", "wait-on": "^9.1.0",
"ws": "^8.21.1", "ws": "^8.21.1",
"yaml": "^2.9.0",
"vite-plus": "0.2.6" "vite-plus": "0.2.6"
}, },
"overrides": { "overrides": {

View file

@ -0,0 +1,33 @@
import { buildSchemas } from "./schemas";
import { writeStateSchemas, bundleMcpJson } from "./openapi";
const command = process.argv[2] ?? "generate";
switch (command) {
case "generate": {
const schemas = buildSchemas();
writeStateSchemas(schemas);
await bundleMcpJson();
console.log(
`${Object.keys(schemas.defs).length} state schemas → openapi.state.yaml + mcp/openapi.json`
);
break;
}
case "dump": {
console.log(JSON.stringify(buildSchemas().defs, null, 2));
break;
}
case "validate": {
const source = process.argv[3];
if (!source) {
console.error("usage: vp run openapi -- validate <payload.json | url>");
process.exit(1);
}
const { validate } = await import("./validate");
if (!(await validate(source))) process.exit(1);
break;
}
default:
console.error(`unknown command: ${command}`);
process.exit(1);
}

View file

@ -0,0 +1,38 @@
import { readFileSync, writeFileSync } from "node:fs";
import { parse, stringify } from "yaml";
import { bundle, createConfig } from "@redocly/openapi-core";
import type { StateSchemas } from "./schemas";
const OPENAPI_PATH = "server/openapi.yaml";
const STATE_PATH = "server/openapi.state.yaml";
const MCP_JSON_PATH = "server/mcp/openapi.json";
const HEADER = `# GENERATED FILE - DO NOT EDIT (source: assets/js/types/evcc.ts, update: make openapi)
`;
export function writeStateSchemas(schemas: StateSchemas): void {
const root = parse(readFileSync(OPENAPI_PATH, "utf8"));
const handWritten = new Set(Object.keys(root.components?.schemas ?? {}));
for (const name of Object.keys(schemas.defs)) {
if (handWritten.has(name)) {
throw new Error(`generated schema "${name}" collides with hand-written component`);
}
}
const doc =
HEADER +
stringify(
{ components: { schemas: schemas.defs } },
{ lineWidth: 0, aliasDuplicateObjects: false }
);
writeFileSync(STATE_PATH, doc);
}
// inline the multi-file spec into the single json embedded by the MCP server
export async function bundleMcpJson(): Promise<void> {
const { bundle: result } = await bundle({ ref: OPENAPI_PATH, config: await createConfig({}) });
const doc = result.parsed as { servers?: unknown };
delete doc.servers; // mcp server sets its own url
writeFileSync(MCP_JSON_PATH, JSON.stringify(doc, null, 2) + "\n");
}

View file

@ -0,0 +1,221 @@
import { createGenerator } from "ts-json-schema-generator";
import type { Schema } from "ts-json-schema-generator";
export interface StateSchemas {
rootName: string;
// schema per component name, root first, rest alphabetical
defs: Record<string, Schema>;
}
// enums use SCREAMING_SNAKE in the frontend, schema names follow openapi PascalCase convention
const RENAME: Record<string, string> = {
CHARGE_MODE: "ChargeMode",
BATTERY_MODE: "BatteryMode",
CURRENCY: "Currency",
CHARGER_STATUS_REASON: "ChargerStatusReason",
PHASE_ACTION: "PhaseAction",
PV_ACTION: "PvAction",
SMART_COST_TYPE: "SmartCostType",
OCPP_STATION_STATUS: "OcppConnectionStatus",
MODBUS_BAUDRATE: "ModbusBaudrate",
MODBUS_COMSET: "ModbusComset",
MODBUS_PROXY_READONLY: "ModbusProxyReadonly",
};
const VALID_NAME = /^[a-zA-Z0-9._-]+$/;
const ROOT = "State";
function buildRawSchema(): Schema {
const generator = createGenerator({
path: "assets/js/types/evcc.ts",
tsconfig: "tsconfig.json",
type: ROOT,
jsDoc: "extended",
extraTags: ["internal"],
additionalProperties: true,
topRef: false,
skipTypeCheck: true,
sortProps: false,
});
return generator.createSchema(ROOT);
}
type AnySchema = any;
function walk(node: any, visit: (schema: AnySchema) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => walk(child, visit));
} else if (node && typeof node === "object") {
visit(node);
Object.values(node).forEach((child) => walk(child, visit));
}
}
// remove properties tagged @internal, they are UI-only and not part of the API payload
function stripInternal(schema: AnySchema): void {
walk(schema, (node) => {
if (!node.properties) return;
for (const [key, prop] of Object.entries<AnySchema>(node.properties)) {
if (prop?.internal === true) {
delete node.properties[key];
if (Array.isArray(node.required)) {
node.required = node.required.filter((r: string) => r !== key);
if (node.required.length === 0) delete node.required;
}
}
}
});
}
function collectRefs(schema: AnySchema): Set<string> {
const refs = new Set<string>();
walk(schema, (node) => {
if (typeof node.$ref === "string") {
refs.add(decodeURIComponent(node.$ref.replace("#/definitions/", "")));
}
});
return refs;
}
// drop definitions that became unreachable after stripping @internal properties
function reachableDefs(
root: AnySchema,
defs: Record<string, AnySchema>
): Record<string, AnySchema> {
const keep: Record<string, AnySchema> = {};
const queue = [...collectRefs(root)];
while (queue.length > 0) {
const name = queue.shift()!;
if (keep[name] || !defs[name]) continue;
keep[name] = defs[name];
queue.push(...collectRefs(defs[name]));
}
return keep;
}
// convert json-schema null unions to `nullable: true`, the 3.0 style used across openapi.yaml
// (kin-openapi, which validates the spec in CI, does not support 3.1 type arrays)
function normalizeNullables(schema: AnySchema): void {
walk(schema, (node) => {
if (Array.isArray(node.type) && node.type.includes("null")) {
const rest = node.type.filter((t: string) => t !== "null");
if (rest.length !== 1) throw new Error(`unsupported type union ${node.type}`);
node.type = rest[0];
node.nullable = true;
}
if (Array.isArray(node.anyOf) && node.anyOf.some((b: AnySchema) => b.type === "null")) {
const rest = node.anyOf.filter((b: AnySchema) => b.type !== "null");
delete node.anyOf;
node.nullable = true;
if (rest.length === 1 && !rest[0].$ref) {
Object.assign(node, rest[0]);
} else {
node.allOf = rest;
}
}
// openapi 3.0 has no `const`, use a single-value enum
if ("const" in node) {
node.enum = [node.const];
delete node.const;
}
// openapi 3.0 uses a singular example
if (Array.isArray(node.examples)) {
node.example = node.examples[0];
delete node.examples;
}
});
}
// point $refs at the final openapi component names
function rewriteRefs(schema: AnySchema, finalNames: Map<string, string>): void {
walk(schema, (node) => {
if (typeof node.$ref !== "string" || node.$ref.startsWith("#/components/")) return;
const name = decodeURIComponent(node.$ref.replace("#/definitions/", ""));
const renamed = finalNames.get(name);
if (!renamed) throw new Error(`unresolved $ref "${node.$ref}"`);
node.$ref = `#/components/schemas/${renamed}`;
});
}
export function buildSchemas(): StateSchemas {
const raw = buildRawSchema() as AnySchema;
const { definitions = {}, $schema: _schema, ...root } = raw;
stripInternal(root);
Object.values(definitions as Record<string, AnySchema>).forEach(stripInternal);
const defs = reachableDefs(root, definitions);
const finalNames = new Map<string, string>();
for (const name of Object.keys(defs)) {
const renamed = RENAME[name] ?? name;
if (!VALID_NAME.test(renamed)) {
throw new Error(`invalid component name "${renamed}", rename the type in evcc.ts`);
}
if ([...finalNames.values()].includes(renamed)) {
throw new Error(`duplicate component name "${renamed}" after renaming`);
}
finalNames.set(name, renamed);
}
rewriteRefs(root, finalNames);
Object.values(defs).forEach((schema) => rewriteRefs(schema, finalNames));
normalizeNullables(root);
Object.values(defs).forEach(normalizeNullables);
const result: Record<string, Schema> = { [ROOT]: root };
for (const [name, schema] of Object.entries(defs)
.map(([name, schema]) => [finalNames.get(name)!, schema] as const)
.sort(([a], [b]) => a.localeCompare(b))) {
result[name] = schema;
}
const serialized = JSON.stringify(result);
if (serialized.includes("#/definitions/")) {
throw new Error("unrewritten $ref to #/definitions/ left in output");
}
const ordered = Object.fromEntries(
Object.entries(result).map(([name, schema]) => [name, orderKeys(schema)])
);
return { rootName: ROOT, defs: ordered };
}
const KEY_ORDER = [
"$ref",
"description",
"type",
"nullable",
"enum",
"format",
"example",
"items",
"properties",
"required",
"additionalProperties",
"anyOf",
"allOf",
];
// stable key order for readable yaml diffs, property order itself is preserved
function orderKeys(node: any): any {
if (Array.isArray(node)) return node.map(orderKeys);
if (!node || typeof node !== "object") return node;
const keys = Object.keys(node).sort((a, b) => {
const ia = KEY_ORDER.indexOf(a);
const ib = KEY_ORDER.indexOf(b);
return (ia === -1 ? KEY_ORDER.length : ia) - (ib === -1 ? KEY_ORDER.length : ib);
});
return Object.fromEntries(
keys.map((key) => [
key,
key === "properties" ? mapValues(node[key], orderKeys) : orderKeys(node[key]),
])
);
}
function mapValues(obj: Record<string, any>, fn: (v: any) => any): Record<string, any> {
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, fn(v)]));
}

View file

@ -0,0 +1,114 @@
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { Ajv2020 } from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
const STATE_SCHEMAS_PATH = "server/openapi.state.yaml";
// intentionally undocumented experimental structures
const IGNORE = new Set(["$.evopt", "$.evopt-batteries"]);
type AnySchema = any;
function resolve(schema: AnySchema, schemas: Record<string, AnySchema>): AnySchema {
if (typeof schema?.$ref === "string") {
return resolve(schemas[schema.$ref.split("/").pop()!], schemas);
}
return schema ?? {};
}
// report payload keys that have no schema property, additionalProperties:true hides them from ajv
function coverage(
schema: AnySchema,
data: any,
path: string,
schemas: Record<string, AnySchema>,
report: Set<string>
): void {
const s = resolve(schema, schemas);
if (s.anyOf) {
if (data === null) return;
const branch = s.anyOf.find((b: AnySchema) => resolve(b, schemas).type !== "null");
if (branch) coverage(branch, data, path, schemas, report);
return;
}
if (Array.isArray(data)) {
if (s.items) data.forEach((item) => coverage(s.items, item, `${path}[]`, schemas, report));
return;
}
if (data !== null && typeof data === "object") {
if (!s.properties) return;
for (const key of Object.keys(data)) {
if (s.properties[key]) {
coverage(s.properties[key], data[key], `${path}.${key}`, schemas, report);
} else if (!IGNORE.has(`${path}.${key}`)) {
report.add(`${path}.${key}`);
}
}
}
}
// openapi uses `nullable: true`, json schema expects explicit null in the type
function expandNullable(node: any): void {
if (Array.isArray(node)) {
node.forEach(expandNullable);
} else if (node && typeof node === "object") {
if (node.nullable === true) {
delete node.nullable;
if (typeof node.type === "string") {
node.type = [node.type, "null"];
} else if (Array.isArray(node.allOf)) {
node.anyOf = [...node.allOf, { type: "null" }];
delete node.allOf;
}
}
Object.values(node).forEach(expandNullable);
}
}
// validate a /api/state payload against the State schema in server/openapi.state.yaml
export function validateState(payload: any): { errors: string[]; undocumented: string[] } {
const doc = parse(readFileSync(STATE_SCHEMAS_PATH, "utf8"));
const schemas: Record<string, AnySchema> = doc.components.schemas;
expandNullable(schemas);
const ajv = new Ajv2020({ strict: false, allErrors: true });
addFormats(ajv);
const validateFn = ajv.compile({
$ref: "#/components/schemas/State",
components: { schemas },
});
const errors = validateFn(payload)
? []
: (validateFn.errors ?? []).map((err) => `${err.instancePath || "/"} ${err.message}`);
const undocumented = new Set<string>();
coverage(schemas["State"], payload, "$", schemas, undocumented);
return { errors, undocumented: [...undocumented].sort() };
}
export async function validate(source: string): Promise<boolean> {
let payload: any;
if (source.startsWith("http")) {
const res = await fetch(`${source.replace(/\/$/, "")}/api/state`);
payload = await res.json();
} else {
payload = JSON.parse(readFileSync(source, "utf8"));
}
const { errors, undocumented } = validateState(payload);
if (errors.length > 0) {
console.error(`${source}: schema violations`);
for (const err of errors.slice(0, 30)) console.error(` ${err}`);
}
if (undocumented.length > 0) {
console.log(`${source}: ${undocumented.length} undocumented keys`);
for (const key of undocumented) console.log(` ${key}`);
}
if (errors.length === 0 && undocumented.length === 0) console.log(`${source}: ok`);
return errors.length === 0;
}

File diff suppressed because it is too large Load diff

View file

@ -216,13 +216,13 @@ Directly controls the mode of all controllable batteries. evcc behavior like 'pr
| Name | Type | Description | | Name | Type | Description |
|------|------|-------------| |------|------|-------------|
| batteryMode | string | Battery mode | | batteryMode | string | Battery operation mode. |
**Example call:** **Example call:**
```json ```json
call setExternalBatteryMode { call setExternalBatteryMode {
"batteryMode": "normal" "batteryMode": "unknown"
} }
``` ```
@ -384,26 +384,6 @@ call setSolarAdjusted {
} }
``` ```
## getState
Returns the complete state of the system. This structure is used by the UI. It can be filtered by JQ to only return a subset of the data.
**Tags:** general
**Arguments:**
| Name | Type | Description |
|------|------|-------------|
| jq | string | Filter the state with JQ |
**Example call:**
```json
call getState {
"jq": "example"
}
```
## removeGlobalSmartCostLimit ## removeGlobalSmartCostLimit
Convenience method to remove limit for all loadpoints at once. Value is applied to each individual loadpoint. Convenience method to remove limit for all loadpoints at once. Value is applied to each individual loadpoint.
@ -1166,6 +1146,26 @@ call updateSession {
} }
``` ```
## getState
Returns the complete state of the system. This structure is used by the UI and also published via websocket and MQTT. It can be filtered by JQ to only return a subset of the data. Note: the response mirrors the internal UI state and carries no compatibility promise. Fields may change or disappear between releases.
**Tags:** state
**Arguments:**
| Name | Type | Description |
|------|------|-------------|
| jq | string | Filter the state with JQ |
**Example call:**
```json
call getState {
"jq": "example"
}
```
## clearCache ## clearCache
Clears all cached data. This resets all cached values from tariffs, vehicle APIs, and other components that use caching. Clears all cached data. This resets all cached values from tariffs, vehicle APIs, and other components that use caching.

View file

@ -1,4 +1,3 @@
package server package server
//go:generate go tool openapi openapi.yaml mcp/openapi.json //go:generate go tool openapi-mcp --doc mcp/openapi.md mcp/openapi.json
//go:generate go tool openapi-mcp --doc mcp/openapi.md openapi.yaml

1829
server/openapi.state.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ info:
servers: servers:
- url: https://demo.evcc.io/api - url: https://demo.evcc.io/api
tags: tags:
- name: state
- name: auth - name: auth
- name: battery - name: battery
- name: db - name: db
@ -449,7 +450,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Mode" $ref: "./openapi.state.yaml#/components/schemas/ChargeMode"
/loadpoints/{id}/phases/{phases}: /loadpoints/{id}/phases/{phases}:
post: post:
operationId: setLoadpointPhases operationId: setLoadpointPhases
@ -523,7 +524,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/StaticEnergyPlan" $ref: "./openapi.state.yaml#/components/schemas/StaticEnergyPlan"
/loadpoints/{id}/plan/repeating/preview/{soc}/{weekdays}/{hourMinuteTime}/{timezone}: /loadpoints/{id}/plan/repeating/preview/{soc}/{weekdays}/{hourMinuteTime}/{timezone}:
get: get:
operationId: previewLoadpointRepeatingPlan operationId: previewLoadpointRepeatingPlan
@ -590,14 +591,14 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PlanStrategy" $ref: "./openapi.state.yaml#/components/schemas/PlanStrategy"
responses: responses:
"200": "200":
description: Success description: Success
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PlanStrategy" $ref: "./openapi.state.yaml#/components/schemas/PlanStrategy"
/loadpoints/{id}/priority/{priority}: /loadpoints/{id}/priority/{priority}:
post: post:
operationId: setLoadpointPriority operationId: setLoadpointPriority
@ -947,11 +948,11 @@ paths:
get: get:
operationId: getState operationId: getState
summary: System state summary: System state
description: "Returns the complete state of the system. This structure is used by the UI. It can be filtered by JQ to only return a subset of the data." description: "Returns the complete state of the system. This structure is used by the UI and also published via websocket and MQTT. It can be filtered by JQ to only return a subset of the data. Note: the response mirrors the internal UI state and carries no compatibility promise. Fields may change or disappear between releases."
externalDocs: externalDocs:
url: https://docs.evcc.io/integrations/rest-api url: https://docs.evcc.io/integrations/rest-api
tags: tags:
- general - state
parameters: parameters:
- name: jq - name: jq
in: query in: query
@ -971,7 +972,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/State" $ref: "./openapi.state.yaml#/components/schemas/State"
/system/log: /system/log:
get: get:
operationId: getSystemLogs operationId: getSystemLogs
@ -1164,7 +1165,7 @@ paths:
type: object type: object
properties: properties:
mode: mode:
$ref: "#/components/schemas/Mode" $ref: "./openapi.state.yaml#/components/schemas/ChargeMode"
/vehicles/{name}/plan/repeating: /vehicles/{name}/plan/repeating:
post: post:
operationId: updateVehicleRepeatingPlans operationId: updateVehicleRepeatingPlans
@ -1183,7 +1184,7 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: "#/components/schemas/RepeatingPlan" $ref: "./openapi.state.yaml#/components/schemas/RepeatingPlan"
responses: responses:
"200": "200":
description: Success description: Success
@ -1192,7 +1193,7 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: "#/components/schemas/RepeatingPlan" $ref: "./openapi.state.yaml#/components/schemas/RepeatingPlan"
/vehicles/{name}/plan/soc: /vehicles/{name}/plan/soc:
delete: delete:
operationId: deleteVehicleSocPlan operationId: deleteVehicleSocPlan
@ -1248,14 +1249,14 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PlanStrategy" $ref: "./openapi.state.yaml#/components/schemas/PlanStrategy"
responses: responses:
"200": "200":
description: Success description: Success
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PlanStrategy" $ref: "./openapi.state.yaml#/components/schemas/PlanStrategy"
/db/backup: /db/backup:
get: get:
operationId: downloadBackup operationId: downloadBackup
@ -1433,16 +1434,6 @@ paths:
description: Invalid parameters or database offline description: Invalid parameters or database offline
components: components:
schemas: schemas:
BatteryMode:
description: Battery mode
type: string
example: normal
enum:
- unknown
- normal
- hold
- charge
- holdcharge
ChangePassword: ChangePassword:
type: object type: object
properties: properties:
@ -1580,14 +1571,6 @@ components:
- INFO - INFO
- DEBUG - DEBUG
- TRACE - TRACE
Mode:
description: "Charging mode."
type: string
enum:
- "off"
- "now"
- "minpv"
- "pv"
Odometer: Odometer:
nullable: true nullable: true
type: "number" type: "number"
@ -1626,51 +1609,10 @@ components:
type: integer type: integer
example: 3600 example: 3600
minimum: 0 minimum: 0
Rate:
type: object
description: A charging interval
properties:
start:
description: Start
$ref: "#/components/schemas/Timestamp"
end:
description: End
$ref: "#/components/schemas/Timestamp"
value:
description: Cost
type: number
minimum: 0
Rates: Rates:
type: array type: array
items: items:
$ref: "#/components/schemas/Rate" $ref: "./openapi.state.yaml#/components/schemas/Rate"
PlanStrategy:
description: Charging plan strategy configuration
type: object
properties:
continuous:
description: "Force continuous planning"
type: boolean
precondition:
description: "Precondition duration in seconds"
type: integer
minimum: 0
RepeatingPlan:
externalDocs:
url: https://docs.evcc.io/en/features/plans#repeating-plans
type: object
properties:
active:
description: "Set plan active."
type: boolean
soc:
$ref: "#/components/schemas/Soc"
time:
$ref: "#/components/schemas/HourMinuteTime"
tz:
$ref: "#/components/schemas/IANATimeZone"
weekdays:
$ref: "#/components/schemas/Weekdays"
Soc: Soc:
description: SOC in % description: SOC in %
type: number type: number
@ -1682,27 +1624,6 @@ components:
type: number type: number
example: 50 example: 50
minimum: 0 minimum: 0
State:
description: "The actual state structure is not documented yet. Most values should be self-explanatory. Note: While the overall structure is quite stable, details may change between releases."
type: object
StaticEnergyPlan:
externalDocs:
url: https://docs.evcc.io/en/features/plans#energy-amount-plan
type: object
properties:
energy:
$ref: "#/components/schemas/Energy"
time:
$ref: "#/components/schemas/Timestamp"
StaticSocPlan:
externalDocs:
url: https://docs.evcc.io/en/features/plans#create-charging-plan
type: object
properties:
soc:
$ref: "#/components/schemas/Soc"
time:
$ref: "#/components/schemas/Timestamp"
Timestamp: Timestamp:
description: Timestamp in RFC3339 format description: Timestamp in RFC3339 format
type: string type: string
@ -1799,7 +1720,7 @@ components:
in: path in: path
required: true required: true
schema: schema:
$ref: "#/components/schemas/Mode" $ref: "./openapi.state.yaml#/components/schemas/ChargeMode"
soc: soc:
name: soc name: soc
description: SOC in % description: SOC in %
@ -1861,7 +1782,7 @@ components:
in: path in: path
required: true required: true
schema: schema:
$ref: "#/components/schemas/BatteryMode" $ref: "./openapi.state.yaml#/components/schemas/BatteryMode"
costLimit: costLimit:
name: cost name: cost
description: Cost limit in configured currency (default EUR) or CO2 limit in g/kWh description: Cost limit in configured currency (default EUR) or CO2 limit in g/kWh

View file

@ -9,6 +9,7 @@ import (
func TestOpenAPIValidation(t *testing.T) { func TestOpenAPIValidation(t *testing.T) {
loader := openapi3.NewLoader() loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = true
doc, err := loader.LoadFromFile("openapi.yaml") doc, err := loader.LoadFromFile("openapi.yaml")
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, doc.Validate(loader.Context)) require.NoError(t, doc.Validate(loader.Context))

28
tests/state-api.spec.ts Normal file
View file

@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { validateState } from "../scripts/state-schema/validate";
test.use({ baseURL: baseUrl() });
const CONFIG = "cmd/demo.yaml";
test.beforeAll(async () => {
await start(CONFIG);
});
test.afterAll(async () => {
await stop();
});
test("/api/state matches the openapi State schema", async ({ request }) => {
// loadpoint values appear over the first update cycles, poll until stable
await expect
.poll(
async () => {
const res = await request.get("/api/state");
const { errors, undocumented } = validateState(await res.json());
return [...errors, ...undocumented];
},
{ timeout: 30000 }
)
.toEqual([]);
});

View file

@ -1,6 +1,6 @@
{ {
"extends": "@vue/tsconfig/tsconfig.dom.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "assets/js/**/*", "tests/**/*"], "include": ["env.d.ts", "assets/js/**/*", "tests/**/*", "scripts/**/*"],
"compilerOptions": { "compilerOptions": {
"allowUnreachableCode": false, "allowUnreachableCode": false,
"allowUnusedLabels": false, "allowUnusedLabels": false,