Compare commits
4 Commits
dee947ce3e
...
codex/anal
| Author | SHA1 | Date | |
|---|---|---|---|
| e61e244c39 | |||
| 2c90404637 | |||
| 3508e7de19 | |||
| adc67e428d |
86
README.md
86
README.md
@@ -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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📚 技术文档
|
## 📚 技术文档
|
||||||
|
|||||||
9
apps/csms/drizzle/0007_unusual_squadron_supreme.sql
Normal file
9
apps/csms/drizzle/0007_unusual_squadron_supreme.sql
Normal 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;
|
||||||
2042
apps/csms/drizzle/meta/0007_snapshot.json
Normal file
2042
apps/csms/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 处理时根据实际用电量和充电桩电价计算写入
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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})`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
2
hardware/pcb/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*-backups
|
||||||
|
|
||||||
2
hardware/pcb/HeliosDAONE.kicad_pcb
Normal file
2
hardware/pcb/HeliosDAONE.kicad_pcb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
(kicad_pcb (version 20241229) (generator "pcbnew") (generator_version "9.0")
|
||||||
|
)
|
||||||
99
hardware/pcb/HeliosDAONE.kicad_prl
Normal file
99
hardware/pcb/HeliosDAONE.kicad_prl
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
418
hardware/pcb/HeliosDAONE.kicad_pro
Normal file
418
hardware/pcb/HeliosDAONE.kicad_pro
Normal 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": {}
|
||||||
|
}
|
||||||
20
hardware/pcb/HeliosDAONE.kicad_sch
Normal file
20
hardware/pcb/HeliosDAONE.kicad_sch
Normal 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)
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user