Revert "API: generate /api/state documentation from UI types" (#32109)

This commit is contained in:
andig 2026-07-24 11:24:23 +02:00 • committed by GitHub
parent cd9812ef14
commit 9c88523682
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 3359 additions and 8732 deletions

View file

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

View file

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

1
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,27 @@
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,
BATTERY_MODE,
LoadpointUi,
PlanStrategy,
LoadpointSuggestion,
} from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
export default defineComponent({
name: "Loadpoint",

View file

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

View file

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

View file

@ -99,9 +99,9 @@ import {
type VehicleStatus,
type Vehicle,
type LoadpointUi,
type PlanStrategy,
type LoadpointSuggestion,
} from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
import BatteryBoostButton from "../Loadpoints/BatteryBoostButton.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 "@/types/evcc";
import type { ForecastSlot, TimeseriesEntry, SolarDetails } from "../components/Forecast/types";
import deepCopy from "./deepClone";
export enum ForecastType {

28
cmd/openapi/openapi.go Normal file
View file

@ -0,0 +1,28 @@
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)
}
}

View file

@ -30,7 +30,6 @@ export default [
"no-param-reassign": "error",
"vue/block-lang": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-empty-object-type": ["error", { allowInterfaces: "with-single-extends" }],
},
}
).map((config) => ({

1
go.mod
View file

@ -257,6 +257,7 @@ require (
tool (
github.com/dmarkham/enumer
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/gokrazy/tools/cmd/gok
go.uber.org/mock/mockgen

412
package-lock.json generated
View file

@ -39,7 +39,6 @@
"@eslint/js": "^9.39.4",
"@jest/types": "^30.4.1",
"@playwright/test": "^1.60.0",
"@redocly/openapi-core": "^2.38.0",
"@storybook/vue3": "^10.4.4",
"@storybook/vue3-vite": "^10.4.4",
"@types/body-parser": "^1.19.6",
@ -57,8 +56,6 @@
"@vue/eslint-config-typescript": "^14.8.0",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"body-parser": "^2.2.2",
"cross-env": "^10.1.0",
"eslint": "^9.39.4",
@ -80,7 +77,6 @@
"rollup-plugin-visualizer": "^7.0.1",
"storybook": "^10.4.6",
"terser": "^5.48.0",
"ts-json-schema-generator": "^2.9.0",
"tsx": "^4.22.4",
"typescript": "^5.9.3",
"typescript-eslint-language-service": "^5.0.5",
@ -90,8 +86,7 @@
"vue-hot-reload-api": "^2.3.4",
"vue-tsc": "^3.3.5",
"wait-on": "^9.0.10",
"ws": "^8.21.0",
"yaml": "^2.9.0"
"ws": "^8.21.0"
},
"engines": {
"node": ">=26.0.0",
@ -2349,23 +2344,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@ -2379,13 +2357,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/js": {
"version": "9.39.4",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
@ -3624,88 +3595,6 @@
"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.50.1",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.50.1.tgz",
"integrity": "sha512-TrdZUK50baxIjJB/y2ROOqfg35ex+GbkHPoJ40kXQXKMOjrfxfYQ9IliiaLqT5H/Y8qbajPrYQN2DLfvcMaHSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "2.7.2"
}
},
"node_modules/@redocly/openapi-core": {
"version": "2.38.0",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-2.38.0.tgz",
"integrity": "sha512-VvUCotkNqVpJwW0zSzxp012a6dgv7dmgeCRCNk3BZ6tpwglLG93Vq6s1Hz6bnCexYwRjWU8NBvS/JBmDewr79Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "^8.18.1",
"@redocly/config": "^0.50.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": "^4.2.0",
"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/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@reteps/dockerfmt": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.4.tgz",
@ -5475,40 +5364,22 @@
}
},
"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==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"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"
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.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": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
@ -6427,13 +6298,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -7697,23 +7561,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
@ -7727,13 +7574,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
"node_modules/esm-resolve": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/esm-resolve/-/esm-resolve-1.0.11.tgz",
@ -7953,23 +7793,6 @@
"dev": true,
"license": "0BSD"
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -8378,16 +8201,6 @@
"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": {
"version": "20.10.6",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.6.tgz",
@ -9298,16 +9111,6 @@
"dev": true,
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
@ -9382,25 +9185,10 @@
"dev": true,
"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==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT"
},
@ -10213,16 +10001,6 @@
"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/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@ -10744,16 +10522,6 @@
"node": ">=18"
}
},
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@ -11427,16 +11195,6 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@ -11813,16 +11571,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -12765,13 +12513,6 @@
"dev": true,
"license": "MIT"
},
"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-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@ -12795,130 +12536,6 @@
"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.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 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.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/ts-json-schema-generator/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"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": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/ts-map/-/ts-map-1.0.3.tgz",
@ -14629,13 +14246,6 @@
"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": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",

View file

@ -3,7 +3,7 @@
"description": "evcc UI",
"author": "evcc-io",
"scripts": {
"build": "npm run openapi && vite build",
"build": "vite build",
"test": "cross-env TZ=Europe/Berlin NODE_OPTIONS=--no-experimental-webstorage vitest",
"lint": "npm run lint:prettier && npm run lint:eslint && npm run lint:tsc && npm run lint:i18n",
"lint:prettier": "prettier assets tests **/*.{yaml,sh} i18n/*.json --write",
@ -11,7 +11,6 @@
"lint:tsc": "vue-tsc --noEmit",
"lint:i18n": "tsx i18n/check.ts",
"dev": "vite",
"openapi": "tsx scripts/state-schema/index.ts",
"playwright": "playwright test --ui",
"playwright:ci": "cross-env CI=true playwright test",
"simulator": "vite tests/simulator",
@ -59,7 +58,6 @@
"@eslint/js": "^9.39.4",
"@jest/types": "^30.4.1",
"@playwright/test": "^1.60.0",
"@redocly/openapi-core": "^2.38.0",
"@storybook/vue3": "^10.4.4",
"@storybook/vue3-vite": "^10.4.4",
"@types/body-parser": "^1.19.6",
@ -77,8 +75,6 @@
"@vue/eslint-config-typescript": "^14.8.0",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"body-parser": "^2.2.2",
"cross-env": "^10.1.0",
"eslint": "^9.39.4",
@ -100,7 +96,6 @@
"rollup-plugin-visualizer": "^7.0.1",
"storybook": "^10.4.6",
"terser": "^5.48.0",
"ts-json-schema-generator": "^2.9.0",
"tsx": "^4.22.4",
"typescript": "^5.9.3",
"typescript-eslint-language-service": "^5.0.5",
@ -110,7 +105,6 @@
"vue-hot-reload-api": "^2.3.4",
"vue-tsc": "^3.3.5",
"wait-on": "^9.0.10",
"ws": "^8.21.0",
"yaml": "^2.9.0"
"ws": "^8.21.0"
}
}

View file

@ -1,33 +0,0 @@
import { buildSchemas } from "./schemas";
import { writeStateSchemas, bundleMcpJson } from "./openapi";
const command = process.argv[2] ?? "generate";
switch (command) {
case "generate": {
const schemas = buildSchemas();
await 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: npm 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

@ -1,40 +0,0 @@
import { readFileSync, writeFileSync } from "node:fs";
import { parse, stringify } from "yaml";
import prettier from "prettier";
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: npm run openapi)
`;
export async function writeStateSchemas(schemas: StateSchemas): Promise<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 }
);
const config = await prettier.resolveConfig(STATE_PATH);
writeFileSync(STATE_PATH, await prettier.format(doc, { ...config, filepath: STATE_PATH }));
}
// 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

@ -1,221 +0,0 @@
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, ...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

@ -1,114 +0,0 @@
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 |
|------|------|-------------|
| batteryMode | string | Battery operation mode. |
| batteryMode | string | Battery mode |
**Example call:**
```json
call setExternalBatteryMode {
"batteryMode": "unknown"
"batteryMode": "normal"
}
```
@ -384,6 +384,26 @@ 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
Convenience method to remove limit for all loadpoints at once. Value is applied to each individual loadpoint.
@ -1146,26 +1166,6 @@ call updateSession {
}
```
## state
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 state {
"jq": "example"
}
```
## clearCache
Clears all cached data. This resets all cached values from tariffs, vehicle APIs, and other components that use caching.

View file

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

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,6 @@ info:
servers:
- url: https://demo.evcc.io/api
tags:
- name: state
- name: auth
- name: battery
- name: db
@ -450,7 +449,7 @@ paths:
content:
application/json:
schema:
$ref: "./openapi.state.yaml#/components/schemas/ChargeMode"
$ref: "#/components/schemas/Mode"
/loadpoints/{id}/phases/{phases}:
post:
operationId: setLoadpointPhases
@ -524,7 +523,7 @@ paths:
content:
application/json:
schema:
$ref: "./openapi.state.yaml#/components/schemas/StaticEnergyPlan"
$ref: "#/components/schemas/StaticEnergyPlan"
/loadpoints/{id}/plan/repeating/preview/{soc}/{weekdays}/{hourMinuteTime}/{timezone}:
get:
operationId: previewLoadpointRepeatingPlan
@ -591,14 +590,14 @@ paths:
content:
application/json:
schema:
$ref: "./openapi.state.yaml#/components/schemas/PlanStrategy"
$ref: "#/components/schemas/PlanStrategy"
responses:
200:
"200":
description: Success
content:
application/json:
schema:
$ref: "./openapi.state.yaml#/components/schemas/PlanStrategy"
$ref: "#/components/schemas/PlanStrategy"
/loadpoints/{id}/priority/{priority}:
post:
operationId: setLoadpointPriority
@ -946,13 +945,13 @@ paths:
$ref: "#/components/responses/NumberResult"
/state:
get:
operationId: state
operationId: getState
summary: System state
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."
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."
externalDocs:
url: https://docs.evcc.io/integrations/rest-api
tags:
- state
- general
parameters:
- name: jq
in: query
@ -972,7 +971,7 @@ paths:
content:
application/json:
schema:
$ref: "./openapi.state.yaml#/components/schemas/State"
$ref: "#/components/schemas/State"
/system/log:
get:
operationId: getSystemLogs
@ -1165,7 +1164,7 @@ paths:
type: object
properties:
mode:
$ref: "./openapi.state.yaml#/components/schemas/ChargeMode"
$ref: "#/components/schemas/Mode"
/vehicles/{name}/plan/repeating:
post:
operationId: updateVehicleRepeatingPlans
@ -1184,7 +1183,7 @@ paths:
schema:
type: array
items:
$ref: "./openapi.state.yaml#/components/schemas/RepeatingPlan"
$ref: "#/components/schemas/RepeatingPlan"
responses:
"200":
description: Success
@ -1193,7 +1192,7 @@ paths:
schema:
type: array
items:
$ref: "./openapi.state.yaml#/components/schemas/RepeatingPlan"
$ref: "#/components/schemas/RepeatingPlan"
/vehicles/{name}/plan/soc:
delete:
operationId: deleteVehicleSocPlan
@ -1249,14 +1248,14 @@ paths:
content:
application/json:
schema:
$ref: "./openapi.state.yaml#/components/schemas/PlanStrategy"
$ref: "#/components/schemas/PlanStrategy"
responses:
200:
"200":
description: Success
content:
application/json:
schema:
$ref: "./openapi.state.yaml#/components/schemas/PlanStrategy"
$ref: "#/components/schemas/PlanStrategy"
/db/backup:
get:
operationId: downloadBackup
@ -1434,6 +1433,16 @@ paths:
description: Invalid parameters or database offline
components:
schemas:
BatteryMode:
description: Battery mode
type: string
example: normal
enum:
- unknown
- normal
- hold
- charge
- holdcharge
ChangePassword:
type: object
properties:
@ -1571,6 +1580,14 @@ components:
- INFO
- DEBUG
- TRACE
Mode:
description: "Charging mode."
type: string
enum:
- "off"
- "now"
- "minpv"
- "pv"
Odometer:
nullable: true
type: "number"
@ -1609,10 +1626,51 @@ components:
type: integer
example: 3600
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:
type: array
items:
$ref: "./openapi.state.yaml#/components/schemas/Rate"
$ref: "#/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:
description: SOC in %
type: number
@ -1624,6 +1682,27 @@ components:
type: number
example: 50
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:
description: Timestamp in RFC3339 format
type: string
@ -1720,7 +1799,7 @@ components:
in: path
required: true
schema:
$ref: "./openapi.state.yaml#/components/schemas/ChargeMode"
$ref: "#/components/schemas/Mode"
soc:
name: soc
description: SOC in %
@ -1782,7 +1861,7 @@ components:
in: path
required: true
schema:
$ref: "./openapi.state.yaml#/components/schemas/BatteryMode"
$ref: "#/components/schemas/BatteryMode"
costLimit:
name: cost
description: Cost limit in configured currency (default EUR) or CO2 limit in g/kWh

View file

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

View file

@ -1,28 +0,0 @@
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",
"include": ["env.d.ts", "assets/js/**/*", "tests/**/*", "scripts/**/*"],
"include": ["env.d.ts", "assets/js/**/*", "tests/**/*"],
"compilerOptions": {
"allowUnreachableCode": false,
"allowUnusedLabels": false,