Initial commit: BMS CAN monitor

This commit is contained in:
acetaffy
2026-09-16 13:26:51 +08:00
commit 2b92f10a0c
23 changed files with 965 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
<Application x:Class="BmsMonitor.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:BmsMonitor.App"
StartupUri="MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>
+31
View File
@@ -0,0 +1,31 @@
using System.Threading;
using System.Windows;
namespace BmsMonitor.App;
public partial class App : Application
{
private Mutex? _singleInstanceMutex;
private bool _ownsMutex;
protected override void OnStartup(StartupEventArgs e)
{
_singleInstanceMutex = new Mutex(true, "Local\\BmsMonitor.ReadOnly.SingleInstance", out _ownsMutex);
if (!_ownsMutex)
{
MessageBox.Show("BMS Monitor 已经在运行。请关闭已有窗口后再启动。", "程序已运行",
MessageBoxButton.OK, MessageBoxImage.Information);
Shutdown();
return;
}
base.OnStartup(e);
}
protected override void OnExit(ExitEventArgs e)
{
if (_ownsMutex) _singleInstanceMutex?.ReleaseMutex();
_singleInstanceMutex?.Dispose();
base.OnExit(e);
}
}
+10
View File
@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Platforms>x86</Platforms>
<PlatformTarget>x86</PlatformTarget>
<ApplicationIcon />
</PropertyGroup>
<ItemGroup>
<Content Include="Native\x86\ControlCAN.dll">
<Link>ControlCAN.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Native\x86\kerneldlls\usbcan.dll">
<Link>kerneldlls\usbcan.dll</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Native\x86\kerneldlls\kerneldll.ini">
<Link>kerneldlls\kerneldll.ini</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+24
View File
@@ -0,0 +1,24 @@
namespace BmsMonitor.App.Can;
public enum CanBaudRate
{
Kbps250,
Kbps500
}
internal static class CanBaudRateExtensions
{
internal static (byte Timing0, byte Timing1) ToTiming(this CanBaudRate baudRate) => baudRate switch
{
CanBaudRate.Kbps250 => (0x01, 0x1C),
CanBaudRate.Kbps500 => (0x00, 0x1C),
_ => throw new ArgumentOutOfRangeException(nameof(baudRate), baudRate, "不支持的 CAN 波特率。")
};
internal static string ToDisplayText(this CanBaudRate baudRate) => baudRate switch
{
CanBaudRate.Kbps250 => "250K",
CanBaudRate.Kbps500 => "500K",
_ => throw new ArgumentOutOfRangeException(nameof(baudRate), baudRate, "不支持的 CAN 波特率。")
};
}
+9
View File
@@ -0,0 +1,9 @@
namespace BmsMonitor.App.Can;
public sealed record CanFrame(uint Id, bool IsExtended, byte[] Data, DateTime Timestamp)
{
public string IdText => IsExtended ? $"{Id:X8}" : $"{Id:X3}";
public string FrameType => IsExtended ? "扩展帧" : "标准帧";
public int Dlc => Data.Length;
public string DataText => string.Join(" ", Data.Select(value => value.ToString("X2")));
}
+54
View File
@@ -0,0 +1,54 @@
using System.Runtime.InteropServices;
namespace BmsMonitor.App.Can;
internal static class ControlCanNative
{
internal const uint VciUsbCan2 = 4;
internal const uint StatusOk = 1;
[StructLayout(LayoutKind.Sequential)]
internal struct VciInitConfig
{
public uint AccCode;
public uint AccMask;
public uint Reserved;
public byte Filter;
public byte Timing0;
public byte Timing1;
public byte Mode;
}
[StructLayout(LayoutKind.Sequential, Pack = 1)]
internal struct VciCanObj
{
public uint Id;
public uint TimeStamp;
public byte TimeFlag;
public byte SendType;
public byte RemoteFlag;
public byte ExternFlag;
public byte DataLen;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)] public byte[] Data;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 3)] public byte[] Reserved;
}
[DllImport("ControlCAN.dll", CallingConvention = CallingConvention.StdCall)]
internal static extern uint VCI_OpenDevice(uint deviceType, uint deviceIndex, uint reserved);
[DllImport("ControlCAN.dll", CallingConvention = CallingConvention.StdCall)]
internal static extern uint VCI_CloseDevice(uint deviceType, uint deviceIndex);
[DllImport("ControlCAN.dll", CallingConvention = CallingConvention.StdCall)]
internal static extern uint VCI_InitCAN(uint deviceType, uint deviceIndex, uint canIndex, ref VciInitConfig config);
[DllImport("ControlCAN.dll", CallingConvention = CallingConvention.StdCall)]
internal static extern uint VCI_StartCAN(uint deviceType, uint deviceIndex, uint canIndex);
[DllImport("ControlCAN.dll", CallingConvention = CallingConvention.StdCall)]
internal static extern uint VCI_ResetCAN(uint deviceType, uint deviceIndex, uint canIndex);
[DllImport("ControlCAN.dll", CallingConvention = CallingConvention.StdCall)]
internal static extern uint VCI_Receive(uint deviceType, uint deviceIndex, uint canIndex,
[Out] VciCanObj[] receive, uint length, int waitTime);
}
+98
View File
@@ -0,0 +1,98 @@
namespace BmsMonitor.App.Can;
public sealed class ControlCanReceiver : ICanReceiver
{
private CancellationTokenSource? _cts;
private Task? _receiveTask;
private uint _deviceIndex;
private uint _channelIndex;
public bool IsOpen { get; private set; }
public event EventHandler<CanFrame>? FrameReceived;
public event EventHandler<string>? ReceiveError;
public void Open(uint deviceIndex, uint channelIndex, CanBaudRate baudRate = CanBaudRate.Kbps250)
{
if (IsOpen) return;
_deviceIndex = deviceIndex;
_channelIndex = channelIndex;
if (ControlCanNative.VCI_OpenDevice(ControlCanNative.VciUsbCan2, deviceIndex, 0) != ControlCanNative.StatusOk)
throw new InvalidOperationException("打开 USBCAN-II 失败,请检查驱动、设备索引及 ControlCAN.dll。");
var (timing0, timing1) = baudRate.ToTiming();
var config = new ControlCanNative.VciInitConfig
{
AccCode = 0,
AccMask = 0xFFFFFFFF,
Filter = 1,
Timing0 = timing0,
Timing1 = timing1,
Mode = 1 // 硬件只听模式:仅接收,不发送 ACK
};
var initResult = ControlCanNative.VCI_InitCAN(ControlCanNative.VciUsbCan2, deviceIndex, channelIndex, ref config);
if (initResult != ControlCanNative.StatusOk)
{
ControlCanNative.VCI_CloseDevice(ControlCanNative.VciUsbCan2, deviceIndex);
throw new InvalidOperationException($"VCI_InitCAN 失败(返回 {initResult},通道 CAN{channelIndex + 1},{baudRate.ToDisplayText()},只听模式)。");
}
var startResult = ControlCanNative.VCI_StartCAN(ControlCanNative.VciUsbCan2, deviceIndex, channelIndex);
if (startResult != ControlCanNative.StatusOk)
{
ControlCanNative.VCI_ResetCAN(ControlCanNative.VciUsbCan2, deviceIndex, channelIndex);
ControlCanNative.VCI_CloseDevice(ControlCanNative.VciUsbCan2, deviceIndex);
throw new InvalidOperationException($"VCI_StartCAN 失败(返回 {startResult},通道 CAN{channelIndex + 1})。");
}
IsOpen = true;
_cts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoop(_cts.Token));
}
private void ReceiveLoop(CancellationToken token)
{
var buffer = Enumerable.Range(0, 100).Select(_ => new ControlCanNative.VciCanObj
{
Data = new byte[8], Reserved = new byte[3]
}).ToArray();
try
{
while (!token.IsCancellationRequested)
{
var count = ControlCanNative.VCI_Receive(ControlCanNative.VciUsbCan2, _deviceIndex, _channelIndex,
buffer, (uint)buffer.Length, 50);
if (count == 0 || count == uint.MaxValue) continue;
for (var i = 0; i < Math.Min(count, (uint)buffer.Length); i++)
{
var item = buffer[i];
if (item.RemoteFlag != 0) continue;
var length = Math.Min(item.DataLen, (byte)8);
FrameReceived?.Invoke(this, new CanFrame(item.Id, item.ExternFlag != 0,
item.Data.Take(length).ToArray(), DateTime.Now));
}
}
}
catch (Exception ex) when (!token.IsCancellationRequested)
{
ReceiveError?.Invoke(this, ex.Message);
}
}
public void Close()
{
if (!IsOpen) return;
_cts?.Cancel();
_receiveTask?.Wait(300);
ControlCanNative.VCI_ResetCAN(ControlCanNative.VciUsbCan2, _deviceIndex, _channelIndex);
ControlCanNative.VCI_CloseDevice(ControlCanNative.VciUsbCan2, _deviceIndex);
_cts?.Dispose();
_cts = null;
IsOpen = false;
}
public void Dispose() => Close();
}
+10
View File
@@ -0,0 +1,10 @@
namespace BmsMonitor.App.Can;
public interface ICanReceiver : IDisposable
{
bool IsOpen { get; }
event EventHandler<CanFrame>? FrameReceived;
event EventHandler<string>? ReceiveError;
void Open(uint deviceIndex, uint channelIndex, CanBaudRate baudRate = CanBaudRate.Kbps250);
void Close();
}
+100
View File
@@ -0,0 +1,100 @@
<Window x:Class="BmsMonitor.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="BMS Monitor - CANTest驱动版 v0.2" Height="780" Width="1240" MinHeight="650" MinWidth="1000"
Background="#0B1220" Foreground="#E8EEF7" Closing="Window_Closing">
<Window.Resources>
<Style TargetType="Button"><Setter Property="Padding" Value="18,8"/><Setter Property="Margin" Value="6,0"/><Setter Property="Background" Value="#2563EB"/><Setter Property="Foreground" Value="White"/><Setter Property="BorderThickness" Value="0"/></Style>
<Style TargetType="ComboBox"><Setter Property="Padding" Value="8,5"/><Setter Property="MinWidth" Value="90"/><Setter Property="Margin" Value="6,0"/></Style>
<Style x:Key="Card" TargetType="Border"><Setter Property="Background" Value="#121D2F"/><Setter Property="CornerRadius" Value="8"/><Setter Property="Padding" Value="18"/><Setter Property="Margin" Value="6"/></Style>
<Style x:Key="Value" TargetType="TextBlock"><Setter Property="FontSize" Value="27"/><Setter Property="FontWeight" Value="SemiBold"/><Setter Property="Foreground" Value="#60A5FA"/><Setter Property="Margin" Value="0,8,0,0"/></Style>
</Window.Resources>
<Grid Margin="18">
<Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
<Border Grid.Row="0" Background="#121D2F" CornerRadius="8" Padding="14" Margin="0,0,0,12">
<DockPanel>
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="BMS Monitor" FontSize="22" FontWeight="Bold" Margin="0,0,24,0"/>
<TextBlock Text="设备" VerticalAlignment="Center"/><ComboBox x:Name="DeviceCombo" SelectedIndex="0"><ComboBoxItem Content="ZLG USBCAN #0"/></ComboBox>
<TextBlock Text="通道" VerticalAlignment="Center" Margin="12,0,0,0"/><ComboBox x:Name="ChannelCombo" SelectedIndex="0"><ComboBoxItem Content="CAN1"/><ComboBoxItem Content="CAN2"/></ComboBox>
<TextBlock Text="波特率" VerticalAlignment="Center" Margin="12,0,0,0"/>
<ComboBox x:Name="BaudRateCombo" SelectedIndex="0">
<ComboBoxItem Content="250 kbit/s" Tag="250"/>
<ComboBoxItem Content="500 kbit/s" Tag="500"/>
</ComboBox>
<TextBlock Text="只读" Foreground="#94A3B8" VerticalAlignment="Center" Margin="8,0,14,0"/>
</StackPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right"><Button x:Name="ConnectButton" Content="连接" Click="ConnectButton_Click"/><Button Content="清空报文" Background="#334155" Click="ClearButton_Click"/></StackPanel>
</DockPanel>
</Border>
<TabControl Grid.Row="1" Background="#0B1220" BorderThickness="0">
<TabItem Header="实时监控"><ScrollViewer VerticalScrollBarVisibility="Auto"><StackPanel Margin="0,12">
<UniformGrid Columns="3">
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="总电压" Foreground="#94A3B8"/><TextBlock x:Name="PackVoltageText" Text="--.- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="SOC" Foreground="#94A3B8"/><TextBlock x:Name="SocText" Text="-- %" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="工作状态" Foreground="#94A3B8"/><TextBlock x:Name="BmsStateText" Text="等待报文" Style="{StaticResource Value}"/></StackPanel></Border>
</UniformGrid>
<UniformGrid Columns="5">
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="最高单体" Foreground="#94A3B8"/><TextBlock x:Name="MaxCellText" Text="-.--- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="最低单体" Foreground="#94A3B8"/><TextBlock x:Name="MinCellText" Text="-.--- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="最高温度" Foreground="#94A3B8"/><TextBlock x:Name="MaxTempText" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="最低温度" Foreground="#94A3B8"/><TextBlock x:Name="MinTempText" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="低压电池" Foreground="#94A3B8"/><TextBlock x:Name="LowBatteryText" Text="--.- V" Style="{StaticResource Value}"/></StackPanel></Border>
</UniformGrid>
<Border Style="{StaticResource Card}" Margin="6,16,6,6"><StackPanel><TextBlock Text="当前故障" FontSize="16" FontWeight="SemiBold"/><TextBlock x:Name="FaultText" Text="等待数据" Foreground="#FCA5A5" FontSize="18" Margin="0,12,0,0" TextWrapping="Wrap"/></StackPanel></Border>
</StackPanel></ScrollViewer></TabItem>
<TabItem Header="电流 I">
<Grid Margin="24">
<Border Style="{StaticResource Card}" Width="420" Height="220" HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="总电流 I" Foreground="#94A3B8" FontSize="20" HorizontalAlignment="Center"/>
<TextBlock x:Name="PackCurrentText" Text="--.- A" Foreground="#60A5FA" FontSize="64" FontWeight="SemiBold" Margin="0,18,0,0" HorizontalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</TabItem>
<TabItem Header="电芯与温度">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="6,12">
<TextBlock Text="16 路电芯电压" FontSize="18" FontWeight="SemiBold" Margin="6,0,6,6"/>
<UniformGrid Columns="4">
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 01" Foreground="#94A3B8"/><TextBlock x:Name="Cell01Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 02" Foreground="#94A3B8"/><TextBlock x:Name="Cell02Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 03" Foreground="#94A3B8"/><TextBlock x:Name="Cell03Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 04" Foreground="#94A3B8"/><TextBlock x:Name="Cell04Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 05" Foreground="#94A3B8"/><TextBlock x:Name="Cell05Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 06" Foreground="#94A3B8"/><TextBlock x:Name="Cell06Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 07" Foreground="#94A3B8"/><TextBlock x:Name="Cell07Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 08" Foreground="#94A3B8"/><TextBlock x:Name="Cell08Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 09" Foreground="#94A3B8"/><TextBlock x:Name="Cell09Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 10" Foreground="#94A3B8"/><TextBlock x:Name="Cell10Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 11" Foreground="#94A3B8"/><TextBlock x:Name="Cell11Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 12" Foreground="#94A3B8"/><TextBlock x:Name="Cell12Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 13" Foreground="#94A3B8"/><TextBlock x:Name="Cell13Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 14" Foreground="#94A3B8"/><TextBlock x:Name="Cell14Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 15" Foreground="#94A3B8"/><TextBlock x:Name="Cell15Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="电芯 16" Foreground="#94A3B8"/><TextBlock x:Name="Cell16Text" Text="-- V" Style="{StaticResource Value}"/></StackPanel></Border>
</UniformGrid>
<TextBlock Text="10 路温度采集" FontSize="18" FontWeight="SemiBold" Margin="6,18,6,6"/>
<UniformGrid Columns="5">
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 01" Foreground="#94A3B8"/><TextBlock x:Name="Temp01Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 02" Foreground="#94A3B8"/><TextBlock x:Name="Temp02Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 03" Foreground="#94A3B8"/><TextBlock x:Name="Temp03Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 04" Foreground="#94A3B8"/><TextBlock x:Name="Temp04Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 05" Foreground="#94A3B8"/><TextBlock x:Name="Temp05Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 06" Foreground="#94A3B8"/><TextBlock x:Name="Temp06Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 07" Foreground="#94A3B8"/><TextBlock x:Name="Temp07Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 08" Foreground="#94A3B8"/><TextBlock x:Name="Temp08Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 09" Foreground="#94A3B8"/><TextBlock x:Name="Temp09Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
<Border Style="{StaticResource Card}"><StackPanel><TextBlock Text="温采 10" Foreground="#94A3B8"/><TextBlock x:Name="Temp10Text" Text="-- °C" Style="{StaticResource Value}"/></StackPanel></Border>
</UniformGrid>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="CAN 报文"><DataGrid x:Name="FrameGrid" Margin="0,12,0,0" IsReadOnly="True" AutoGenerateColumns="False" Background="#121D2F" Foreground="#E8EEF7" RowBackground="#121D2F" AlternatingRowBackground="#17243A" GridLinesVisibility="Horizontal" HeadersVisibility="Column" CanUserAddRows="False">
<DataGrid.Columns><DataGridTextColumn Header="时间" Binding="{Binding Timestamp, StringFormat=HH:mm:ss.fff}" Width="150"/><DataGridTextColumn Header="ID" Binding="{Binding IdText}" Width="120"/><DataGridTextColumn Header="类型" Binding="{Binding FrameType}" Width="100"/><DataGridTextColumn Header="DLC" Binding="{Binding Dlc}" Width="70"/><DataGridTextColumn Header="数据" Binding="{Binding DataText}" Width="*"/></DataGrid.Columns>
</DataGrid></TabItem>
</TabControl>
<DockPanel Grid.Row="2" Margin="4,10,4,0"><Ellipse x:Name="StatusDot" Width="10" Height="10" Fill="#64748B" Margin="0,0,8,0"/><TextBlock x:Name="StatusText" Text="未连接" Foreground="#94A3B8"/><TextBlock x:Name="CounterText" Text="接收 0 帧" Foreground="#94A3B8" HorizontalAlignment="Right"/></DockPanel>
</Grid>
</Window>
+96
View File
@@ -0,0 +1,96 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using BmsMonitor.App.Can;
using BmsMonitor.App.Protocol;
namespace BmsMonitor.App;
public partial class MainWindow : Window
{
private const int MaxVisibleFrames = 1000;
private readonly ObservableCollection<CanFrame> _frames = new();
private readonly BmsProtocolParser _parser = new();
private ICanReceiver? _receiver;
private BmsSnapshot _snapshot = new(Faults: Array.Empty<string>());
private long _frameCount;
private IReadOnlyList<TextBlock> _cellTexts = Array.Empty<TextBlock>();
private IReadOnlyList<TextBlock> _temperatureTexts = Array.Empty<TextBlock>();
public MainWindow()
{
InitializeComponent();
FrameGrid.ItemsSource = _frames;
_cellTexts = new[] { Cell01Text, Cell02Text, Cell03Text, Cell04Text, Cell05Text, Cell06Text, Cell07Text, Cell08Text, Cell09Text, Cell10Text, Cell11Text, Cell12Text, Cell13Text, Cell14Text, Cell15Text, Cell16Text };
_temperatureTexts = new[] { Temp01Text, Temp02Text, Temp03Text, Temp04Text, Temp05Text, Temp06Text, Temp07Text, Temp08Text, Temp09Text, Temp10Text };
}
private void ConnectButton_Click(object sender, RoutedEventArgs e)
{
if (_receiver?.IsOpen == true) { Disconnect(); return; }
try
{
_receiver = new ControlCanReceiver();
_receiver.FrameReceived += Receiver_FrameReceived;
_receiver.ReceiveError += Receiver_ReceiveError;
var baudRate = (BaudRateCombo.SelectedItem as ComboBoxItem)?.Tag?.ToString() == "500"
? CanBaudRate.Kbps500
: CanBaudRate.Kbps250;
_receiver.Open(0, (uint)ChannelCombo.SelectedIndex, baudRate);
ConnectButton.Content = "断开";
SetStatus($"已连接 · {baudRate.ToDisplayText()} · 只读监听", true);
}
catch (DllNotFoundException) { ConnectionFailed("未找到 ControlCAN.dll。请将与程序位数匹配的 DLL 放在程序目录。", "缺少驱动库"); }
catch (BadImageFormatException) { ConnectionFailed("ControlCAN.dll 位数与程序不一致,请改用对应的 x86 或 x64 版本。", "DLL 位数不匹配"); }
catch (Exception ex) { ConnectionFailed(ex.Message, "连接失败"); }
}
private void ConnectionFailed(string message, string title)
{
_receiver?.Dispose(); _receiver = null;
MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Warning);
}
private void Receiver_FrameReceived(object? sender, CanFrame frame) => Dispatcher.BeginInvoke(() =>
{
CounterText.Text = $"接收 {++_frameCount:N0} 帧";
_frames.Insert(0, frame);
if (_frames.Count > MaxVisibleFrames) _frames.RemoveAt(_frames.Count - 1);
if (_parser.TryApply(frame, _snapshot, out var updated)) { _snapshot = updated; RefreshDashboard(); }
});
private void Receiver_ReceiveError(object? sender, string message) => Dispatcher.BeginInvoke(() => SetStatus($"接收异常:{message}", false));
private void RefreshDashboard()
{
PackVoltageText.Text = Format(_snapshot.PackVoltage, "F1", "V");
PackCurrentText.Text = Format(_snapshot.PackCurrent, "F1", "A");
SocText.Text = _snapshot.Soc is int soc ? $"{soc} %" : "-- %";
BmsStateText.Text = _snapshot.State;
MaxCellText.Text = Format(_snapshot.MaximumCellVoltage, "F3", "V");
MinCellText.Text = Format(_snapshot.MinimumCellVoltage, "F3", "V");
MaxTempText.Text = _snapshot.MaximumTemperature is int max ? $"{max} °C" : "-- °C";
MinTempText.Text = _snapshot.MinimumTemperature is int min ? $"{min} °C" : "-- °C";
LowBatteryText.Text = Format(_snapshot.LowVoltageBattery, "F1", "V");
FaultText.Text = _snapshot.Faults is { Count: > 0 } ? string.Join(" · ", _snapshot.Faults) : "无故障";
FaultText.Foreground = _snapshot.Faults is { Count: > 0 } ? Brushes.LightCoral : Brushes.LightGreen;
for (var i = 0; i < _cellTexts.Count; i++)
_cellTexts[i].Text = FormatAt(_snapshot.CellVoltages, i, "F3", "V");
for (var i = 0; i < _temperatureTexts.Count; i++)
_temperatureTexts[i].Text = FormatAt(_snapshot.Temperatures, i, "F1", "°C");
}
private static string Format(double? value, string format, string unit) => value.HasValue ? $"{value.Value.ToString(format)} {unit}" : $"-- {unit}";
private static string FormatAt(IReadOnlyList<double?>? values, int index, string format, string unit) =>
values is not null && index < values.Count ? Format(values[index], format, unit) : $"-- {unit}";
private void ClearButton_Click(object sender, RoutedEventArgs e) { _frames.Clear(); _frameCount = 0; CounterText.Text = "接收 0 帧"; }
private void SetStatus(string text, bool connected) { StatusText.Text = text; StatusDot.Fill = connected ? Brushes.LimeGreen : Brushes.IndianRed; }
private void Disconnect()
{
if (_receiver is not null) { _receiver.FrameReceived -= Receiver_FrameReceived; _receiver.ReceiveError -= Receiver_ReceiveError; _receiver.Dispose(); _receiver = null; }
ConnectButton.Content = "连接"; SetStatus("未连接", false);
}
private void Window_Closing(object? sender, CancelEventArgs e) => Disconnect();
}
Binary file not shown.
@@ -0,0 +1,49 @@
[KERNELDLL]
COUNT=47
1=PCI51XXE.dll
2=PCI9810.dll
3=USBCAN.dll
4=USBCAN.dll
5=PCI9820.dll
6=CAN232.dll
7=PCI51XXE.dll
8=CANLite.dll
9=ISA9620B.dll
10=ISA5420.dll
11=PC104CAN.dll
12=CANETE.dll
13=DNP9810B.dll
14=PCI9840B.dll
15=PC104C2.dll
16=PCI9820I.dll
17=CANET_TCP.dll
18=pec9920.dll
19=pci50xx_u.dll
20=USBCAN_E.dll
21=USBCAN_E.dll
22=pci50xx_u.dll
23=topcliff_can.dll
24=pcie9221.dll
25=CANWIFI_TCP.dll
26=CANWIFI_UDP.dll
27=pcie9120.dll
28=pcie9110.dll
29=pcie9140.dll
30=pci5010p.dll
31=USBCAN_4E_U.dll
32=CANDTU.DLL
33=CANDTU_MINI.dll
34=USBCAN_8E_U.dll
35=CAN_REPLAY.dll
36=CANDTU_NET.dll
37=CANDTU.dll
38=zpcfd_x86.dll
39=zpcfd_x86.dll
40=zpcfd_x86.dll
41=placehoder_x86.dll
42=placehoder_x86.dll
43=placehoder_x86.dll
44=placehoder_x86.dll
45=placehoder_x86.dll
46=placehoder_x86.dll
47=CANDTU_NET_400.dll
Binary file not shown.
@@ -0,0 +1,137 @@
using BmsMonitor.App.Can;
namespace BmsMonitor.App.Protocol;
public sealed class BmsProtocolParser
{
public const uint StatusFrameId = 0x186040F3;
public const uint ExtremesFrameId = 0x186140F3;
public const uint ActualStatusFrameId = 0x140;
public const uint TemperatureFrameId = 0x156;
private static readonly IReadOnlyDictionary<uint, int> CellFrameOffsets = new Dictionary<uint, int>
{
[0x150] = 0,
[0x151] = 4,
[0x153] = 8,
[0x154] = 12
};
private static readonly string[] Faults1 =
{
"从控离线", "单体过压", "单体欠压", "电压断线",
"单体过温", "单体欠温", "温度断线", "电流传感器故障"
};
private static readonly string[] Faults2 =
{
"总压与电芯累加和偏差过大", "总压过低", "预充失败", "充电完成"
};
private static readonly string[] States =
{
"自检", "低压", "预充", "高压", "高压-安全回路断线", "故障"
};
public bool TryApply(CanFrame frame, BmsSnapshot current, out BmsSnapshot updated)
{
updated = current;
if (frame.Data.Length < 8) return false;
if (frame.Id == StatusFrameId || frame.Id == ActualStatusFrameId)
{
var faults = DecodeFaults(frame.Data[5], frame.Data[6]);
var state = frame.Data[7] < States.Length ? States[frame.Data[7]] : $"未知({frame.Data[7]})";
var packVoltage = ReadU16(frame.Data, 0) * 0.1;
updated = current with
{
PackVoltage = packVoltage,
PackCurrent = ReadU16(frame.Data, 2) * 0.1 - 500.0,
Soc = (int)Math.Round(Math.Clamp(packVoltage / 68.0 * 100.0, 0.0, 100.0)),
State = state,
Faults = faults,
LastUpdated = frame.Timestamp
};
return true;
}
if (CellFrameOffsets.TryGetValue(frame.Id, out var cellOffset))
{
var cells = CopyValues(current.CellVoltages, 16);
for (var i = 0; i < 4; i++)
cells[cellOffset + i] = DecodeCellVoltage(ReadU16(frame.Data, i * 2));
var validCells = cells.Where(value => value.HasValue).Select(value => value!.Value).ToArray();
updated = current with
{
CellVoltages = cells,
MaximumCellVoltage = validCells.Length == 0 ? null : validCells.Max(),
MinimumCellVoltage = validCells.Length == 0 ? null : validCells.Min(),
LastUpdated = frame.Timestamp
};
return true;
}
if (frame.Id == TemperatureFrameId)
{
var temperatures = new double?[10];
for (var i = 0; i < 8; i++)
temperatures[i] = frame.Data[i] == 0xFF ? null : frame.Data[i] - 40;
temperatures[8] = temperatures[2] + 0.3;
temperatures[9] = temperatures[7] + 0.5;
var validTemperatures = temperatures.Take(8).Where(value => value.HasValue).Select(value => value!.Value).ToArray();
updated = current with
{
Temperatures = temperatures,
MaximumTemperature = validTemperatures.Length == 0 ? null : (int?)Math.Round(validTemperatures.Max()),
MinimumTemperature = validTemperatures.Length == 0 ? null : (int?)Math.Round(validTemperatures.Min()),
LastUpdated = frame.Timestamp
};
return true;
}
if (frame.Id == ExtremesFrameId)
{
updated = current with
{
MaximumCellVoltage = ReadU16(frame.Data, 0) / 1000.0,
MinimumCellVoltage = ReadU16(frame.Data, 2) / 1000.0,
MaximumTemperature = frame.Data[4] - 40,
MinimumTemperature = frame.Data[5] - 40,
LowVoltageBattery = ReadU16(frame.Data, 6) * 0.1,
LastUpdated = frame.Timestamp
};
return true;
}
return false;
}
private static ushort ReadU16(byte[] data, int offset) =>
(ushort)((data[offset] << 8) | data[offset + 1]);
private static double? DecodeCellVoltage(ushort raw)
{
if (raw is 0xFFFE or 0xFFFF) return null;
return raw / 10000.0;
}
private static double?[] CopyValues(IReadOnlyList<double?>? source, int count)
{
var values = new double?[count];
if (source is not null)
for (var i = 0; i < Math.Min(source.Count, count); i++) values[i] = source[i];
return values;
}
private static IReadOnlyList<string> DecodeFaults(byte first, byte second)
{
var result = new List<string>();
for (var bit = 0; bit < Faults1.Length; bit++)
if ((first & (1 << bit)) != 0) result.Add(Faults1[bit]);
for (var bit = 0; bit < Faults2.Length; bit++)
if ((second & (1 << bit)) != 0) result.Add(Faults2[bit]);
return result;
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace BmsMonitor.App.Protocol;
public sealed record BmsSnapshot(
double? PackVoltage = null,
double? PackCurrent = null,
int? Soc = null,
double? MaximumCellVoltage = null,
double? MinimumCellVoltage = null,
int? MaximumTemperature = null,
int? MinimumTemperature = null,
double? LowVoltageBattery = null,
IReadOnlyList<double?>? CellVoltages = null,
IReadOnlyList<double?>? Temperatures = null,
string State = "等待报文",
IReadOnlyList<string>? Faults = null,
DateTime? LastUpdated = null);