Compare commits

..

4 Commits

Author SHA1 Message Date
e61e244c39 chore: generate migration 2026-03-18 15:44:43 +08:00
2c90404637 Unify charge point command channel status 2026-03-18 15:26:56 +08:00
3508e7de19 docs: update README.md 2026-03-18 13:05:49 +08:00
adc67e428d chore(pcb): initial kicad project 2026-03-18 12:47:56 +08:00
24 changed files with 2970 additions and 148 deletions

View File

@@ -4,7 +4,7 @@ _这是一个毕业设计项目旨在尝试实现一个完整的电动汽车
## 📋 项目概述 ## 📋 项目概述
Helios EVCS 是一个全栈解决方案,用于管理和监控电动汽车充电基础设施。项目包含后端 CSMS充电管理系统前端 Web 云平台应用。并设计了一个基于 ESP32演示用的充电桩终端固件和 PCB 设计。 Helios EVCS 是一个全栈解决方案,用于管理和监控电动汽车充电基础设施。项目包含 CSMS充电管理系统前端应用和后端服务。并设计了一个基于 ESP32 的,可演示用的充电桩终端固件和 PCB 设计。
## 🏗️ 项目结构 ## 🏗️ 项目结构
@@ -15,19 +15,18 @@ helios-evcs/
│ └── web/ # 前端云平台应用Next.js 15 + React 19[submodule] │ └── web/ # 前端云平台应用Next.js 15 + React 19[submodule]
├── hardware/ # 硬件和固件工程 ├── hardware/ # 硬件和固件工程
│ ├── firmware/ # 充电桩固件 │ ├── firmware/ # 充电桩固件
│ └── pcb-kicad/ # PCB 设计文件KiCAD │ └── pcb/ # PCB 设计文件KiCAD
├── package.json ├── package.json
├── pnpm-workspace.yaml ├── pnpm-workspace.yaml
── pnpm-lock.yaml ── pnpm-lock.yaml
└── .gitmodules
``` ```
## 📦 工作区包 ## 📦 工作区包
### `apps/csms` - 充电管理系统后端 ### `apps/csms` - CSMS 后端
- **技术栈**Node.js + Hono + TypeScript - **技术栈**Node.js + Hono + TypeScript
- **端口**3001(默认) - **端口**3001
- **职责** - **职责**
- OCPP 协议实现 - OCPP 协议实现
- 充电设备管理 - 充电设备管理
@@ -35,16 +34,17 @@ helios-evcs/
- 实时数据处理 - 实时数据处理
- RESTful API 接口 - RESTful API 接口
### `apps/web` - 前端管理界面(子模块) ### `apps/web` - CSMS 前端
- **技术栈**Next.js 15 + React 19 + Tailwind CSS - **技术栈**Next.js 16 + React 19 + Tailwind CSS
- **端口**3000(默认) - **端口**3000
- **源仓库**https://github.com/HoshinoSuzumi/helios
- **职责** - **职责**
- 充电管理界面 - 充电管理
- 实时监控面板 - 概览监控面板
- 数据可视化 - 储值卡管理
- 用户管理 - 用户管理
- 充电会话历史
- 远程启动/停止充电
### `hardware/` - 硬件和固件工程 ### `hardware/` - 硬件和固件工程
@@ -52,17 +52,18 @@ helios-evcs/
- 设备驱动实现 - 设备驱动实现
- 通信协议栈 - 通信协议栈
- 实时控制逻辑 - 实时控制逻辑
- **pcb-kicad/** - PCB 电路板设计 - **pcb/** - PCB 电路板设计
- KiCAD 工程文件 - KiCAD 工程文件
- 电路原理图 - 电路原理图
- PCB 布局设计 - PCB 布局设计
- BOM 物料清单 - BOM 物料清单
- 制造文件Gerber
## 🚀 快速开始 ## 🚀 快速开始
### 前置要求 ### 前置要求
- Node.js >= 18 - Node.js >= 20
- pnpm >= 10.18.2 - pnpm >= 10.18.2
### 安装依赖 ### 安装依赖
@@ -78,6 +79,8 @@ pnpm install
# 启动所有开发服务器CSMS + Web # 启动所有开发服务器CSMS + Web
pnpm dev pnpm dev
# 迁移数据库
pnpm --filter csms run db:migrate
# 仅启动后端 # 仅启动后端
pnpm dev:csms pnpm dev:csms
@@ -111,21 +114,11 @@ pnpm start:csms
# 启动前端 # 启动前端
pnpm start:web pnpm start:web
# 生产环境使用 docker compose
docker compose up -d --build
``` ```
## 📝 Scripts 说明
| 命令 | 描述 |
| ----------------- | ---------------------- |
| `pnpm dev` | 启动所有服务的开发模式 |
| `pnpm dev:csms` | 启动后端开发服务器 |
| `pnpm dev:web` | 启动前端开发服务器 |
| `pnpm build` | 构建所有包 |
| `pnpm build:csms` | 构建后端 |
| `pnpm build:web` | 构建前端 |
| `pnpm start:csms` | 生产环境启动后端 |
| `pnpm start:web` | 生产环境启动前端 |
## 🔧 工作区管理 ## 🔧 工作区管理
### 使用 pnpm filter 运行特定包命令 ### 使用 pnpm filter 运行特定包命令
@@ -133,7 +126,7 @@ pnpm start:web
```bash ```bash
# 在指定包中运行命令 # 在指定包中运行命令
pnpm --filter csms <command> pnpm --filter csms <command>
pnpm --filter helios-web <command> pnpm --filter web <command>
# 示例:在 csms 中运行测试 # 示例:在 csms 中运行测试
pnpm --filter csms run test pnpm --filter csms run test
@@ -143,42 +136,11 @@ pnpm --filter csms run test
```bash ```bash
# 在根安装(所有包共用) # 在根安装(所有包共用)
pnpm add <package> pnpm add <package> -w
# 在特定包中安装 # 在特定包中安装
pnpm --filter csms add <package> pnpm --filter csms add <package>
pnpm --filter helios-web add <package> pnpm --filter web add <package>
```
## 📁 Git 子模块管理
`apps/web` 作为 Git 子模块管理:
```bash
# 初始化并更新子模块
git submodule update --init --recursive
# 更新子模块到最新版本
git submodule update --remote
# 克隆包含子模块的仓库
git clone --recurse-submodules <repo-url>
```
在子模块内修改后的工作流:
```bash
# 在 apps/web 目录内提交更改
cd apps/web
git add .
git commit -m "feat: xxx"
git push
# 返回主仓库,更新子模块引用
cd ..
git add apps/web
git commit -m "chore: update helios submodule"
git push
``` ```
## 📚 技术文档 ## 📚 技术文档

View File

@@ -0,0 +1,9 @@
ALTER TABLE "charge_point" ADD COLUMN "last_ws_connected_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "last_ws_disconnected_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "connection_session_id" varchar(64);--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "transport_status" varchar DEFAULT 'offline' NOT NULL;--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "last_command_status" varchar;--> statement-breakpoint
ALTER TABLE "charge_point" ADD COLUMN "last_command_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "transaction" ADD COLUMN "remote_stop_requested_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "transaction" ADD COLUMN "remote_stop_request_id" varchar(64);--> statement-breakpoint
ALTER TABLE "transaction" ADD COLUMN "remote_stop_status" varchar;

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,13 @@
"when": 1773682931777, "when": 1773682931777,
"tag": "0006_spooky_skin", "tag": "0006_spooky_skin",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1773819865056,
"tag": "0007_unusual_squadron_supreme",
"breakpoints": true
} }
] ]
} }

View File

@@ -62,6 +62,29 @@ export const chargePoint = pgTable('charge_point', {
heartbeatInterval: integer('heartbeat_interval').default(60), heartbeatInterval: integer('heartbeat_interval').default(60),
/** 最后一次收到 Heartbeat.req 的时间UTC */ /** 最后一次收到 Heartbeat.req 的时间UTC */
lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }), lastHeartbeatAt: timestamp('last_heartbeat_at', { withTimezone: true }),
/** 最近一次 WebSocket 连接建立时间UTC */
lastWsConnectedAt: timestamp('last_ws_connected_at', { withTimezone: true }),
/** 最近一次 WebSocket 连接关闭时间UTC */
lastWsDisconnectedAt: timestamp('last_ws_disconnected_at', { withTimezone: true }),
/** 最近一次活跃 WebSocket 会话 ID用于区分重连代次 */
connectionSessionId: varchar('connection_session_id', { length: 64 }),
/**
* OCPP 传输通道状态:
* online = 当前实例持有活动 WebSocket
* unavailable = 最近有心跳,但当前无可用下行通道
* offline = 无活动通道
*/
transportStatus: varchar('transport_status', {
enum: ['online', 'unavailable', 'offline'],
})
.notNull()
.default('offline'),
/** 最近一次下行命令确认结果 */
lastCommandStatus: varchar('last_command_status', {
enum: ['Accepted', 'Rejected', 'Error', 'Timeout'],
}),
/** 最近一次下行命令确认时间UTC */
lastCommandAt: timestamp('last_command_at', { withTimezone: true }),
/** 最后一次收到 BootNotification.req 的时间UTC */ /** 最后一次收到 BootNotification.req 的时间UTC */
lastBootNotificationAt: timestamp('last_boot_notification_at', { lastBootNotificationAt: timestamp('last_boot_notification_at', {
withTimezone: true, withTimezone: true,
@@ -398,6 +421,16 @@ export const transaction = pgTable(
'DeAuthorized', 'DeAuthorized',
], ],
}), }),
/** 管理端发起远程停止的请求时间 */
remoteStopRequestedAt: timestamp('remote_stop_requested_at', {
withTimezone: true,
}),
/** 管理端发起远程停止的 OCPP uniqueId */
remoteStopRequestId: varchar('remote_stop_request_id', { length: 64 }),
/** 最近一次远程停止请求的结果 */
remoteStopStatus: varchar('remote_stop_status', {
enum: ['Requested', 'Accepted', 'Rejected', 'Error', 'Timeout'],
}),
/** /**
* 本次充电扣费金额(单位:分) * 本次充电扣费金额(单位:分)
* 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入 * 由 StopTransaction 处理时根据实际用电量和充电桩电价计算写入

View File

@@ -32,6 +32,7 @@ export async function handleBootNotification(
registrationStatus: 'Pending', registrationStatus: 'Pending',
heartbeatInterval, heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(), lastBootNotificationAt: dayjs().toDate(),
transportStatus: 'online',
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: chargePoint.chargePointIdentifier, target: chargePoint.chargePointIdentifier,
@@ -47,6 +48,7 @@ export async function handleBootNotification(
// Do NOT override registrationStatus — preserve whatever the admin set // Do NOT override registrationStatus — preserve whatever the admin set
heartbeatInterval, heartbeatInterval,
lastBootNotificationAt: dayjs().toDate(), lastBootNotificationAt: dayjs().toDate(),
transportStatus: 'online',
updatedAt: dayjs().toDate(), updatedAt: dayjs().toDate(),
}, },
}) })

View File

@@ -16,7 +16,11 @@ export async function handleHeartbeat(
await db await db
.update(chargePoint) .update(chargePoint)
.set({ lastHeartbeatAt: dayjs().toDate() }) .set({
lastHeartbeatAt: dayjs().toDate(),
transportStatus: 'online',
updatedAt: dayjs().toDate(),
})
.where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier)) .where(eq(chargePoint.chargePointIdentifier, ctx.chargePointIdentifier))
return { return {

View File

@@ -26,6 +26,11 @@ export async function handleStopTransaction(
stopMeterValue: payload.meterStop, stopMeterValue: payload.meterStop,
stopIdTag: payload.idTag ?? null, stopIdTag: payload.idTag ?? null,
stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null, stopReason: (payload.reason as (typeof transaction.$inferSelect)["stopReason"]) ?? null,
remoteStopStatus: sql`case
when ${transaction.remoteStopRequestedAt} is not null and coalesce(${transaction.remoteStopStatus}, 'Requested') in ('Requested', 'Accepted')
then 'Accepted'
else ${transaction.remoteStopStatus}
end`,
updatedAt: dayjs().toDate(), updatedAt: dayjs().toDate(),
}) })
.where(eq(transaction.id, payload.transactionId)) .where(eq(transaction.id, payload.transactionId))

View File

@@ -1,11 +1,18 @@
import type { WSContext } from 'hono/ws' import type { WSContext } from 'hono/ws'
import dayjs from 'dayjs'
import { eq } from 'drizzle-orm'
import { isSupportedOCPP } from '@/constants.js' import { isSupportedOCPP } from '@/constants.js'
import { useDrizzle } from '@/lib/db.js'
import { chargePoint } from '@/db/schema.js'
import { import {
OCPP_MESSAGE_TYPE, OCPP_MESSAGE_TYPE,
type OcppCall, type OcppCall,
type OcppCallErrorMessage,
type OcppCallResultMessage,
type OcppErrorCode, type OcppErrorCode,
type OcppMessage, type OcppMessage,
type OcppConnectionContext, type OcppConnectionContext,
type CommandChannelStatus,
type AuthorizeRequest, type AuthorizeRequest,
type AuthorizeResponse, type AuthorizeResponse,
type BootNotificationRequest, type BootNotificationRequest,
@@ -24,9 +31,26 @@ import {
/** /**
* Global registry of active OCPP WebSocket connections. * Global registry of active OCPP WebSocket connections.
* Key = chargePointIdentifier, Value = WSContext * Key = chargePointIdentifier, Value = connection entry
*/ */
export const ocppConnections = new Map<string, WSContext>() export type OcppConnectionEntry = {
ws: WSContext
sessionId: string
openedAt: Date
lastMessageAt: Date
}
type PendingCall =
| {
chargePointIdentifier: string
action: string
resolve: (payload: unknown) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
export const ocppConnections = new Map<string, OcppConnectionEntry>()
const pendingCalls = new Map<string, PendingCall>()
import { handleAuthorize } from './actions/authorize.ts' import { handleAuthorize } from './actions/authorize.ts'
import { handleBootNotification } from './actions/boot-notification.ts' import { handleBootNotification } from './actions/boot-notification.ts'
import { handleHeartbeat } from './actions/heartbeat.ts' import { handleHeartbeat } from './actions/heartbeat.ts'
@@ -92,6 +116,80 @@ function sendCallError(
) )
} }
async function updateTransportState(
chargePointIdentifier: string,
values: Partial<typeof chargePoint.$inferInsert>,
): Promise<void> {
const db = useDrizzle()
await db
.update(chargePoint)
.set({
...values,
updatedAt: dayjs().toDate(),
})
.where(eq(chargePoint.chargePointIdentifier, chargePointIdentifier))
}
function getCommandChannelStatus(chargePointIdentifier: string): CommandChannelStatus {
return ocppConnections.has(chargePointIdentifier) ? 'online' : 'unavailable'
}
export async function sendOcppCall<TPayload extends Record<string, unknown>, TResult = unknown>(
chargePointIdentifier: string,
action: string,
payload: TPayload,
timeoutOrOptions: number | { timeoutMs?: number; uniqueId?: string } = 15000,
): Promise<TResult> {
const entry = ocppConnections.get(chargePointIdentifier)
if (!entry) {
await updateTransportState(chargePointIdentifier, { transportStatus: 'unavailable' })
throw new Error('TransportUnavailable')
}
const timeoutMs =
typeof timeoutOrOptions === 'number' ? timeoutOrOptions : (timeoutOrOptions.timeoutMs ?? 15000)
const uniqueId =
typeof timeoutOrOptions === 'number' ? crypto.randomUUID() : (timeoutOrOptions.uniqueId ?? crypto.randomUUID())
const resultPromise = new Promise<TResult>((resolve, reject) => {
const timeout = setTimeout(async () => {
pendingCalls.delete(uniqueId)
await updateTransportState(chargePointIdentifier, {
transportStatus: getCommandChannelStatus(chargePointIdentifier),
lastCommandStatus: 'Timeout',
lastCommandAt: dayjs().toDate(),
})
reject(new Error('CommandTimeout'))
}, timeoutMs)
pendingCalls.set(uniqueId, {
chargePointIdentifier,
action,
resolve: (response) => resolve(response as TResult),
reject,
timeout,
})
})
try {
entry.ws.send(JSON.stringify([OCPP_MESSAGE_TYPE.CALL, uniqueId, action, payload]))
} catch (error) {
const pending = pendingCalls.get(uniqueId)
if (pending) {
clearTimeout(pending.timeout)
pendingCalls.delete(uniqueId)
}
await updateTransportState(chargePointIdentifier, {
transportStatus: 'unavailable',
lastCommandStatus: 'Error',
lastCommandAt: dayjs().toDate(),
})
throw error instanceof Error ? error : new Error('CommandSendFailed')
}
return resultPromise
}
/** /**
* Factory that produces a hono-ws event handler object for a single * Factory that produces a hono-ws event handler object for a single
* OCPP WebSocket connection. * OCPP WebSocket connection.
@@ -104,15 +202,26 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
chargePointIdentifier, chargePointIdentifier,
isRegistered: false, isRegistered: false,
} }
const sessionId = crypto.randomUUID()
return { return {
onOpen(_evt: Event, ws: WSContext) { async onOpen(_evt: Event, ws: WSContext) {
const subProtocol = ws.protocol ?? 'unknown' const subProtocol = ws.protocol ?? 'unknown'
if (!isSupportedOCPP(subProtocol)) { if (!isSupportedOCPP(subProtocol)) {
ws.close(1002, 'Unsupported subprotocol') ws.close(1002, 'Unsupported subprotocol')
return return
} }
ocppConnections.set(chargePointIdentifier, ws) ocppConnections.set(chargePointIdentifier, {
ws,
sessionId,
openedAt: new Date(),
lastMessageAt: new Date(),
})
await updateTransportState(chargePointIdentifier, {
transportStatus: 'online',
connectionSessionId: sessionId,
lastWsConnectedAt: dayjs().toDate(),
})
console.log( console.log(
`[OCPP] ${chargePointIdentifier} connected` + `[OCPP] ${chargePointIdentifier} connected` +
(remoteAddr ? ` from ${remoteAddr}` : ''), (remoteAddr ? ` from ${remoteAddr}` : ''),
@@ -122,6 +231,11 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
async onMessage(evt: MessageEvent, ws: WSContext) { async onMessage(evt: MessageEvent, ws: WSContext) {
let uniqueId = '(unknown)' let uniqueId = '(unknown)'
try { try {
const current = ocppConnections.get(chargePointIdentifier)
if (current) {
current.lastMessageAt = new Date()
}
const raw = evt.data const raw = evt.data
if (typeof raw !== 'string') return if (typeof raw !== 'string') return
@@ -141,7 +255,36 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
const [messageType, msgUniqueId] = message const [messageType, msgUniqueId] = message
uniqueId = String(msgUniqueId) uniqueId = String(msgUniqueId)
// CSMS only handles CALL messages from the charge point if (messageType === OCPP_MESSAGE_TYPE.CALLRESULT) {
const [, responseUniqueId, payload] = message as OcppCallResultMessage
const pending = pendingCalls.get(responseUniqueId)
if (!pending) return
clearTimeout(pending.timeout)
pendingCalls.delete(responseUniqueId)
await updateTransportState(pending.chargePointIdentifier, {
transportStatus: getCommandChannelStatus(pending.chargePointIdentifier),
lastCommandStatus: 'Accepted',
lastCommandAt: dayjs().toDate(),
})
pending.resolve(payload)
return
}
if (messageType === OCPP_MESSAGE_TYPE.CALLERROR) {
const [, responseUniqueId, errorCode, errorDescription] = message as OcppCallErrorMessage
const pending = pendingCalls.get(responseUniqueId)
if (!pending) return
clearTimeout(pending.timeout)
pendingCalls.delete(responseUniqueId)
await updateTransportState(pending.chargePointIdentifier, {
transportStatus: getCommandChannelStatus(pending.chargePointIdentifier),
lastCommandStatus: errorCode === 'InternalError' ? 'Error' : 'Rejected',
lastCommandAt: dayjs().toDate(),
})
pending.reject(new Error(`${errorCode}:${errorDescription}`))
return
}
if (messageType !== OCPP_MESSAGE_TYPE.CALL) return if (messageType !== OCPP_MESSAGE_TYPE.CALL) return
const [, , action, payload] = message as OcppCall const [, , action, payload] = message as OcppCall
@@ -174,8 +317,15 @@ export function createOcppHandler(chargePointIdentifier: string, remoteAddr?: st
} }
}, },
onClose(evt: CloseEvent, _ws: WSContext) { async onClose(evt: CloseEvent, _ws: WSContext) {
ocppConnections.delete(chargePointIdentifier) const current = ocppConnections.get(chargePointIdentifier)
if (current?.sessionId === sessionId) {
ocppConnections.delete(chargePointIdentifier)
await updateTransportState(chargePointIdentifier, {
transportStatus: 'offline',
lastWsDisconnectedAt: dayjs().toDate(),
})
}
console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`) console.log(`[OCPP] ${chargePointIdentifier} disconnected (code=${evt.code})`)
}, },
} }

View File

@@ -34,6 +34,11 @@ export type OcppConnectionContext = {
isRegistered: boolean isRegistered: boolean
} }
export type OcppCallResultMessage = [3, string, Record<string, unknown>]
export type OcppCallErrorMessage = [4, string, OcppErrorCode, string, Record<string, unknown>]
export type CommandChannelStatus = 'online' | 'unavailable' | 'offline'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Action payload types (OCPP 1.6-J Section 4.x) // Action payload types (OCPP 1.6-J Section 4.x)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -26,7 +26,9 @@ app.get("/", async (c) => {
db db
.select({ count: sql<number>`count(*)::int` }) .select({ count: sql<number>`count(*)::int` })
.from(chargePoint) .from(chargePoint)
.where(sql`${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`), .where(
sql`${chargePoint.transportStatus} = 'online' and ${chargePoint.lastHeartbeatAt} > now() - interval '120 seconds'`,
),
db db
.select({ count: sql<number>`count(*)::int` }) .select({ count: sql<number>`count(*)::int` })
.from(transaction) .from(transaction)

View File

@@ -5,8 +5,7 @@ import { useDrizzle } from "@/lib/db.js";
import { transaction, chargePoint, connector, idTag } from "@/db/schema.js"; import { transaction, chargePoint, connector, idTag } from "@/db/schema.js";
import type { SampledValue } from "@/db/schema.js"; import type { SampledValue } from "@/db/schema.js";
import { user } from "@/db/auth-schema.js"; import { user } from "@/db/auth-schema.js";
import { ocppConnections } from "@/ocpp/handler.js"; import { sendOcppCall } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js";
import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js"; import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js";
import type { HonoEnv } from "@/types/hono.ts"; import type { HonoEnv } from "@/types/hono.ts";
@@ -84,19 +83,28 @@ app.post("/remote-start", async (c) => {
return c.json({ error: "ChargePoint is not accepted" }, 400); return c.json({ error: "ChargePoint is not accepted" }, 400);
} }
// Require the charge point to be online try {
const ws = ocppConnections.get(body.chargePointIdentifier.trim()); const response = await sendOcppCall<
if (!ws) return c.json({ error: "ChargePoint is offline" }, 503); { connectorId: number; idTag: string },
{ status?: string }
>(body.chargePointIdentifier.trim(), "RemoteStartTransaction", {
connectorId: body.connectorId,
idTag: body.idTag.trim(),
})
const uniqueId = crypto.randomUUID(); if (response?.status && response.status !== "Accepted") {
ws.send( return c.json({ error: `RemoteStartTransaction ${response.status}` }, 409)
JSON.stringify([ }
OCPP_MESSAGE_TYPE.CALL, } catch (error) {
uniqueId, const message = error instanceof Error ? error.message : "CommandSendFailed"
"RemoteStartTransaction", if (message === "TransportUnavailable") {
{ connectorId: body.connectorId, idTag: body.idTag.trim() }, return c.json({ error: "ChargePoint command channel is unavailable" }, 503)
]), }
); if (message === "CommandTimeout") {
return c.json({ error: "ChargePoint did not confirm RemoteStartTransaction in time" }, 504)
}
return c.json({ error: `RemoteStartTransaction failed: ${message}` }, 502)
}
console.log( console.log(
`[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` + `[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` +
@@ -311,9 +319,8 @@ app.get("/:id", async (c) => {
/** /**
* POST /api/transactions/:id/stop * POST /api/transactions/:id/stop
* Manually stop an active transaction. * Manually stop an active transaction.
* 1. If the charge point is connected, send OCPP RemoteStopTransaction. * 1. If the charge point is connected, send OCPP RemoteStopTransaction and wait for confirmation.
* 2. In either case (online or offline), settle the transaction in the DB immediately * 2. Record the stop request state in DB; final settlement still happens on StopTransaction.
* so the record is always finalised from the admin side.
*/ */
app.post("/:id/stop", async (c) => { app.post("/:id/stop", async (c) => {
const db = useDrizzle(); const db = useDrizzle();
@@ -324,7 +331,6 @@ app.post("/:id/stop", async (c) => {
.select({ .select({
transaction, transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier, chargePointIdentifier: chargePoint.chargePointIdentifier,
feePerKwh: chargePoint.feePerKwh,
}) })
.from(transaction) .from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id)) .leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
@@ -336,55 +342,74 @@ app.post("/:id/stop", async (c) => {
const now = dayjs(); const now = dayjs();
// Try to send RemoteStopTransaction via OCPP if the charge point is online let remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" = "Requested";
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null; let remoteStopRequestId: string | null = null;
if (ws) { let online = false;
const uniqueId = crypto.randomUUID();
ws.send( if (row.chargePointIdentifier) {
JSON.stringify([ remoteStopRequestId = crypto.randomUUID();
OCPP_MESSAGE_TYPE.CALL, try {
uniqueId, const response = await sendOcppCall<{ transactionId: number }, { status?: string }>(
row.chargePointIdentifier,
"RemoteStopTransaction", "RemoteStopTransaction",
{ transactionId: row.transaction.id }, { transactionId: row.transaction.id },
]), { uniqueId: remoteStopRequestId },
); )
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`); online = true;
remoteStopStatus = response?.status === "Accepted" ? "Accepted" : "Rejected";
console.log(`[OCPP] RemoteStopTransaction txId=${id} status=${response?.status ?? "unknown"} to ${row.chargePointIdentifier}`);
} catch (error) {
const message = error instanceof Error ? error.message : "CommandSendFailed";
remoteStopStatus = message === "CommandTimeout" ? "Timeout" : "Error";
online = message !== "TransportUnavailable";
console.warn(`[OCPP] RemoteStopTransaction txId=${id} failed for ${row.chargePointIdentifier}: ${message}`);
if (message === "TransportUnavailable") {
return c.json(
{
error: "ChargePoint command channel is unavailable",
online: false,
remoteStopStatus,
},
503,
);
}
if (message === "CommandTimeout") {
return c.json(
{
error: "ChargePoint did not confirm RemoteStopTransaction in time",
online: true,
remoteStopStatus,
},
504,
);
}
return c.json(
{
error: `RemoteStopTransaction failed: ${message}`,
online,
remoteStopStatus,
},
502,
);
}
} }
// Settle in DB regardless (charge point may be offline or slow to respond)
// Use startMeterValue as stopMeterValue when the real value is unknown (offline case)
const stopMeterValue = row.transaction.startMeterValue;
const energyWh = 0; // cannot know actual energy without stop meter value
const feePerKwh = row.feePerKwh ?? 0;
const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0;
const [updated] = await db const [updated] = await db
.update(transaction) .update(transaction)
.set({ .set({
stopTimestamp: now.toDate(), remoteStopRequestedAt: now.toDate(),
stopMeterValue, remoteStopRequestId,
stopReason: "Remote", remoteStopStatus,
chargeAmount: feeFen,
updatedAt: now.toDate(), updatedAt: now.toDate(),
}) })
.where(eq(transaction.id, id)) .where(eq(transaction.id, id))
.returning(); .returning();
if (feeFen > 0) {
await db
.update(idTag)
.set({
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
updatedAt: now.toDate(),
})
.where(eq(idTag.idTag, row.transaction.idTag));
}
return c.json({ return c.json({
...updated, ...updated,
chargePointIdentifier: row.chargePointIdentifier, chargePointIdentifier: row.chargePointIdentifier,
online: !!ws, online,
energyWh, remoteStopStatus,
}); });
}); });

View File

@@ -180,8 +180,16 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
// Online if last heartbeat within 3× interval // Online if last heartbeat within 3× interval
const isOnline = const isOnline =
cp?.transportStatus === "online" &&
cp?.lastHeartbeatAt != null && cp?.lastHeartbeatAt != null &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3; dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
const commandChannelUnavailable = cp?.transportStatus === "unavailable";
const statusLabel = isOnline ? "在线" : commandChannelUnavailable ? "通道异常" : "离线";
const statusDotClass = isOnline
? "bg-success animate-pulse"
: commandChannelUnavailable
? "bg-warning"
: "bg-muted";
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const isAdmin = sessionData?.user?.role === "admin"; const isAdmin = sessionData?.user?.role === "admin";
@@ -245,9 +253,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
</Chip> </Chip>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span <span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`} className={`size-2 rounded-full ${statusDotClass}`}
/> />
<span className="text-xs text-muted">{isOnline ? "在线" : "离线"}</span> <span className="text-xs text-muted">{statusLabel}</span>
</div> </div>
</div> </div>
{(cp.chargePointVendor || cp.chargePointModel) && ( {(cp.chargePointVendor || cp.chargePointModel) && (
@@ -437,9 +445,9 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
<dd> <dd>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span <span
className={`size-2 rounded-full ${isOnline ? "bg-success animate-pulse" : "bg-muted"}`} className={`size-2 rounded-full ${statusDotClass}`}
/> />
<span className="text-sm text-foreground">{isOnline ? "在线" : "离线"}</span> <span className="text-sm text-foreground">{statusLabel}</span>
</div> </div>
</dd> </dd>
</div> </div>

View File

@@ -574,18 +574,22 @@ export default function ChargePointsPage() {
{isAdmin && <Table.Cell>{""}</Table.Cell>} {isAdmin && <Table.Cell>{""}</Table.Cell>}
</Table.Row> </Table.Row>
)} )}
{chargePoints.map((cp) => ( {chargePoints.map((cp) => {
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}> const online =
cp.transportStatus === "online" &&
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const commandChannelUnavailable = cp.transportStatus === "unavailable";
return (
<Table.Row key={cp.id} id={String(cp.id)} className={"group"}>
<Table.Cell> <Table.Cell>
<Tooltip delay={0}> <Tooltip delay={0}>
<Tooltip.Trigger> <Tooltip.Trigger>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={`size-2 shrink-0 rounded-full ${ className={`size-2 shrink-0 rounded-full ${
cp.lastHeartbeatAt && online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-gray-300"
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "bg-success"
: "bg-gray-300"
}`} }`}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -604,11 +608,7 @@ export default function ChargePointsPage() {
</div> </div>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content placement="start"> <Tooltip.Content placement="start">
{cp.lastHeartbeatAt {online ? "在线" : commandChannelUnavailable ? "通道异常" : cp.lastHeartbeatAt ? "离线" : "从未连接"}
? dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120
? "在线"
: "离线"
: "从未连接"}
</Tooltip.Content> </Tooltip.Content>
</Tooltip> </Tooltip>
</Table.Cell> </Table.Cell>
@@ -751,8 +751,9 @@ export default function ChargePointsPage() {
</div> </div>
</Table.Cell> </Table.Cell>
)} )}
</Table.Row> </Table.Row>
))} );
})}
</Table.Body> </Table.Body>
</Table.Content> </Table.Content>
</Table.ScrollContainer> </Table.ScrollContainer>

View File

@@ -363,8 +363,11 @@ function ChargePageContent() {
const msg = err.message ?? ""; const msg = err.message ?? "";
const lowerMsg = msg.toLowerCase(); const lowerMsg = msg.toLowerCase();
if (lowerMsg.includes("offline")) setStartError("充电桩当前不在线,请稍后再试"); if (lowerMsg.includes("command channel is unavailable") || lowerMsg.includes("offline")) {
else if ( setStartError("充电桩下行通道不可用,请稍后再试");
} else if (lowerMsg.includes("did not confirm remotestarttransaction in time")) {
setStartError("充电桩未及时确认启动指令,请稍后重试");
} else if (
lowerMsg.includes("chargepoint is not accepted") || lowerMsg.includes("chargepoint is not accepted") ||
lowerMsg.includes("not accepted") lowerMsg.includes("not accepted")
) { ) {
@@ -596,7 +599,10 @@ function ChargePageContent() {
.filter((cp) => cp.registrationStatus === "Accepted") .filter((cp) => cp.registrationStatus === "Accepted")
.map((cp) => { .map((cp) => {
const online = const online =
!!cp.lastHeartbeatAt && dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120; cp.transportStatus === "online" &&
!!cp.lastHeartbeatAt &&
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
const commandChannelUnavailable = cp.transportStatus === "unavailable";
const availableCount = cp.connectors.filter( const availableCount = cp.connectors.filter(
(c) => c.status === "Available", (c) => c.status === "Available",
).length; ).length;
@@ -644,13 +650,17 @@ function ChargePageContent() {
"shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold", "shrink-0 inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold",
online online
? "bg-success/12 text-success" ? "bg-success/12 text-success"
: commandChannelUnavailable
? "bg-warning/12 text-warning"
: "bg-surface-tertiary text-muted", : "bg-surface-tertiary text-muted",
].join(" ")} ].join(" ")}
> >
<span <span
className={`size-1.5 rounded-full ${online ? "bg-success" : "bg-muted"}`} className={`size-1.5 rounded-full ${
online ? "bg-success" : commandChannelUnavailable ? "bg-warning" : "bg-muted"
}`}
/> />
{online ? "在线" : "离线"} {online ? "在线" : commandChannelUnavailable ? "通道异常" : "离线"}
</span> </span>
</div> </div>
{/* Bottom row: connectors + fee */} {/* Bottom row: connectors + fee */}

View File

@@ -35,7 +35,7 @@ function timeAgo(dateStr: string | null | undefined): string {
} }
function cpOnline(cp: ChargePoint): boolean { function cpOnline(cp: ChargePoint): boolean {
if (!cp.lastHeartbeatAt) return false; if (cp.transportStatus !== "online" || !cp.lastHeartbeatAt) return false;
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120; return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
} }

View File

@@ -26,7 +26,8 @@ import { Clock, EvCharger, Plug, Zap } from "lucide-react";
type ConnectionStatus = "online" | "stale" | "offline"; type ConnectionStatus = "online" | "stale" | "offline";
function getStatus(cp: ChargePoint, connected: string[]): ConnectionStatus { function getStatus(cp: ChargePoint, connected: string[]): ConnectionStatus {
if (!connected.includes(cp.chargePointIdentifier)) return "offline"; if (cp.transportStatus === "unavailable") return "stale";
if (cp.transportStatus !== "online" || !connected.includes(cp.chargePointIdentifier)) return "offline";
if (!cp.lastHeartbeatAt) return "stale"; if (!cp.lastHeartbeatAt) return "stale";
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "minute") < 5 ? "online" : "stale"; return dayjs().diff(dayjs(cp.lastHeartbeatAt), "minute") < 5 ? "online" : "stale";
} }
@@ -36,7 +37,7 @@ const STATUS_CONFIG: Record<
{ color: string; edgeColor: string; label: string; animated: boolean } { color: string; edgeColor: string; label: string; animated: boolean }
> = { > = {
online: { color: "#22c55e", edgeColor: "#22c55e", label: "在线", animated: true }, online: { color: "#22c55e", edgeColor: "#22c55e", label: "在线", animated: true },
stale: { color: "#f59e0b", edgeColor: "#f59e0b", label: "心跳超时", animated: true }, stale: { color: "#f59e0b", edgeColor: "#f59e0b", label: "通道异常", animated: true },
offline: { color: "#71717a", edgeColor: "#9ca3af", label: "离线", animated: false }, offline: { color: "#71717a", edgeColor: "#9ca3af", label: "离线", animated: false },
}; };

View File

@@ -72,6 +72,8 @@ export type ConnectionsStatus = {
connectedIdentifiers: string[]; connectedIdentifiers: string[];
}; };
export type ChargePointConnectionStatus = "online" | "unavailable" | "offline";
export type ChargePoint = { export type ChargePoint = {
id: string; id: string;
chargePointIdentifier: string; chargePointIdentifier: string;
@@ -79,7 +81,12 @@ export type ChargePoint = {
chargePointVendor: string | null; chargePointVendor: string | null;
chargePointModel: string | null; chargePointModel: string | null;
registrationStatus: string; registrationStatus: string;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null; lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null; lastBootNotificationAt: string | null;
feePerKwh: number; feePerKwh: number;
pricingMode: "fixed" | "tou"; pricingMode: "fixed" | "tou";
@@ -102,7 +109,12 @@ export type ChargePointDetail = {
meterType: string | null; meterType: string | null;
registrationStatus: string; registrationStatus: string;
heartbeatInterval: number | null; heartbeatInterval: number | null;
transportStatus: ChargePointConnectionStatus;
lastHeartbeatAt: string | null; lastHeartbeatAt: string | null;
lastWsConnectedAt: string | null;
lastWsDisconnectedAt: string | null;
lastCommandStatus: "Accepted" | "Rejected" | "Error" | "Timeout" | null;
lastCommandAt: string | null;
lastBootNotificationAt: string | null; lastBootNotificationAt: string | null;
feePerKwh: number; feePerKwh: number;
pricingMode: "fixed" | "tou"; pricingMode: "fixed" | "tou";
@@ -134,6 +146,9 @@ export type Transaction = {
chargeAmount: number | null; chargeAmount: number | null;
electricityFee: number | null; electricityFee: number | null;
serviceFee: number | null; serviceFee: number | null;
remoteStopStatus: "Requested" | "Accepted" | "Rejected" | "Error" | "Timeout" | null;
remoteStopRequestedAt: string | null;
remoteStopRequestId: string | null;
}; };
export type IdTag = { export type IdTag = {

2
hardware/pcb/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*-backups

View File

@@ -0,0 +1,2 @@
(kicad_pcb (version 20241229) (generator "pcbnew") (generator_version "9.0")
)

View File

@@ -0,0 +1,99 @@
{
"board": {
"active_layer": 0,
"active_layer_preset": "",
"auto_track_width": true,
"hidden_netclasses": [],
"hidden_nets": [],
"high_contrast_mode": 0,
"net_color_mode": 1,
"opacity": {
"images": 0.6,
"pads": 1.0,
"shapes": 1.0,
"tracks": 1.0,
"vias": 1.0,
"zones": 0.6
},
"selection_filter": {
"dimensions": true,
"footprints": true,
"graphics": true,
"keepouts": true,
"lockedItems": false,
"otherItems": true,
"pads": true,
"text": true,
"tracks": true,
"vias": true,
"zones": true
},
"visible_items": [
"vias",
"footprint_text",
"footprint_anchors",
"ratsnest",
"grid",
"footprints_front",
"footprints_back",
"footprint_values",
"footprint_references",
"tracks",
"drc_errors",
"drawing_sheet",
"bitmaps",
"pads",
"zones",
"drc_warnings",
"drc_exclusions",
"locked_item_shadows",
"conflict_shadows",
"shapes"
],
"visible_layers": "ffffffff_ffffffff_ffffffff_ffffffff",
"zone_display_mode": 0
},
"git": {
"integration_disabled": false,
"repo_type": "",
"repo_username": "",
"ssh_key": ""
},
"meta": {
"filename": "HeliosDAONE.kicad_prl",
"version": 5
},
"net_inspector_panel": {
"col_hidden": [],
"col_order": [],
"col_widths": [],
"custom_group_rules": [],
"expanded_rows": [],
"filter_by_net_name": true,
"filter_by_netclass": true,
"filter_text": "",
"group_by_constraint": false,
"group_by_netclass": false,
"show_unconnected_nets": false,
"show_zero_pad_nets": false,
"sort_ascending": true,
"sorting_column": -1
},
"open_jobsets": [],
"project": {
"files": []
},
"schematic": {
"selection_filter": {
"graphics": true,
"images": true,
"labels": true,
"lockedItems": false,
"otherItems": true,
"pins": true,
"symbols": true,
"text": true,
"wires": true
}
}
}

View File

@@ -0,0 +1,418 @@
{
"board": {
"3dviewports": [],
"design_settings": {
"defaults": {},
"diff_pair_dimensions": [],
"drc_exclusions": [],
"rules": {},
"track_widths": [],
"via_dimensions": []
},
"ipc2581": {
"dist": "",
"distpn": "",
"internal_id": "",
"mfg": "",
"mpn": ""
},
"layer_pairs": [],
"layer_presets": [],
"viewports": []
},
"boards": [],
"cvpcb": {
"equivalence_files": []
},
"erc": {
"erc_exclusions": [],
"meta": {
"version": 0
},
"pin_map": [
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
1,
0,
1,
2
],
[
0,
1,
0,
0,
0,
0,
1,
1,
2,
1,
1,
2
],
[
0,
0,
0,
0,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
2
],
[
1,
1,
1,
1,
1,
0,
1,
1,
1,
1,
1,
2
],
[
0,
0,
0,
1,
0,
0,
1,
0,
0,
0,
0,
2
],
[
0,
2,
1,
2,
0,
0,
1,
0,
2,
2,
2,
2
],
[
0,
2,
0,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
0,
2,
1,
1,
0,
0,
1,
0,
2,
0,
0,
2
],
[
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2,
2
]
],
"rule_severities": {
"bus_definition_conflict": "error",
"bus_entry_needed": "error",
"bus_to_bus_conflict": "error",
"bus_to_net_conflict": "error",
"different_unit_footprint": "error",
"different_unit_net": "error",
"duplicate_reference": "error",
"duplicate_sheet_names": "error",
"endpoint_off_grid": "warning",
"extra_units": "error",
"footprint_filter": "ignore",
"footprint_link_issues": "warning",
"four_way_junction": "ignore",
"global_label_dangling": "warning",
"hier_label_mismatch": "error",
"label_dangling": "error",
"label_multiple_wires": "warning",
"lib_symbol_issues": "warning",
"lib_symbol_mismatch": "warning",
"missing_bidi_pin": "warning",
"missing_input_pin": "warning",
"missing_power_pin": "error",
"missing_unit": "warning",
"multiple_net_names": "warning",
"net_not_bus_member": "warning",
"no_connect_connected": "warning",
"no_connect_dangling": "warning",
"pin_not_connected": "error",
"pin_not_driven": "error",
"pin_to_pin": "warning",
"power_pin_not_driven": "error",
"same_local_global_label": "warning",
"similar_label_and_power": "warning",
"similar_labels": "warning",
"similar_power": "warning",
"simulation_model_issue": "ignore",
"single_global_label": "ignore",
"unannotated": "error",
"unconnected_wire_endpoint": "warning",
"undefined_netclass": "error",
"unit_value_mismatch": "error",
"unresolved_variable": "error",
"wire_dangling": "error"
}
},
"libraries": {
"pinned_footprint_libs": [],
"pinned_symbol_libs": []
},
"meta": {
"filename": "HeliosDAONE.kicad_pro",
"version": 3
},
"net_settings": {
"classes": [
{
"bus_width": 12,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_via_gap": 0.25,
"diff_pair_width": 0.2,
"line_style": 0,
"microvia_diameter": 0.3,
"microvia_drill": 0.1,
"name": "Default",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"priority": 2147483647,
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.2,
"via_diameter": 0.6,
"via_drill": 0.3,
"wire_width": 6
}
],
"meta": {
"version": 4
},
"net_colors": null,
"netclass_assignments": null,
"netclass_patterns": []
},
"pcbnew": {
"last_paths": {
"gencad": "",
"idf": "",
"netlist": "",
"plot": "",
"pos_files": "",
"specctra_dsn": "",
"step": "",
"svg": "",
"vrml": ""
},
"page_layout_descr_file": ""
},
"schematic": {
"annotate_start_num": 0,
"bom_export_filename": "${PROJECTNAME}.csv",
"bom_fmt_presets": [],
"bom_fmt_settings": {
"field_delimiter": ",",
"keep_line_breaks": false,
"keep_tabs": false,
"name": "CSV",
"ref_delimiter": ",",
"ref_range_delimiter": "",
"string_delimiter": "\""
},
"bom_presets": [],
"bom_settings": {
"exclude_dnp": false,
"fields_ordered": [
{
"group_by": false,
"label": "Reference",
"name": "Reference",
"show": true
},
{
"group_by": false,
"label": "Qty",
"name": "${QUANTITY}",
"show": true
},
{
"group_by": true,
"label": "Value",
"name": "Value",
"show": true
},
{
"group_by": true,
"label": "DNP",
"name": "${DNP}",
"show": true
},
{
"group_by": true,
"label": "Exclude from BOM",
"name": "${EXCLUDE_FROM_BOM}",
"show": true
},
{
"group_by": true,
"label": "Exclude from Board",
"name": "${EXCLUDE_FROM_BOARD}",
"show": true
},
{
"group_by": true,
"label": "Footprint",
"name": "Footprint",
"show": true
},
{
"group_by": false,
"label": "Datasheet",
"name": "Datasheet",
"show": true
}
],
"filter_string": "",
"group_symbols": true,
"include_excluded_from_bom": true,
"name": "Default Editing",
"sort_asc": true,
"sort_field": "位号"
},
"connection_grid_size": 50.0,
"drawing": {
"dashed_lines_dash_length_ratio": 12.0,
"dashed_lines_gap_length_ratio": 3.0,
"default_line_thickness": 6.0,
"default_text_size": 50.0,
"field_names": [],
"intersheets_ref_own_page": false,
"intersheets_ref_prefix": "",
"intersheets_ref_short": false,
"intersheets_ref_show": false,
"intersheets_ref_suffix": "",
"junction_size_choice": 3,
"label_size_ratio": 0.375,
"operating_point_overlay_i_precision": 3,
"operating_point_overlay_i_range": "~A",
"operating_point_overlay_v_precision": 3,
"operating_point_overlay_v_range": "~V",
"overbar_offset_ratio": 1.23,
"pin_symbol_size": 25.0,
"text_offset_ratio": 0.15
},
"legacy_lib_dir": "",
"legacy_lib_list": [],
"meta": {
"version": 1
},
"net_format_name": "",
"page_layout_descr_file": "",
"plot_directory": "",
"space_save_all_events": true,
"spice_current_sheet_as_root": false,
"spice_external_command": "spice \"%I\"",
"spice_model_current_sheet_as_root": true,
"spice_save_all_currents": false,
"spice_save_all_dissipations": false,
"spice_save_all_voltages": false,
"subpart_first_id": 65,
"subpart_id_separator": 0
},
"sheets": [
[
"ef4a6f87-87d3-400e-b11f-0c7519b83474",
"Root"
]
],
"text_variables": {}
}

View File

@@ -0,0 +1,20 @@
(kicad_sch
(version 20250114)
(generator "eeschema")
(generator_version "9.0")
(uuid "ef4a6f87-87d3-400e-b11f-0c7519b83474")
(paper "A4")
(title_block
(title "Helios DA ONE")
(date "2026-02-18")
(rev "1.0.0")
(company "RayineElec")
)
(lib_symbols)
(sheet_instances
(path "/"
(page "1")
)
)
(embedded_fonts no)
)