65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Windows IoT 将引擎数据流式传输到 Azure

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2018 年 8 月 8 日

CPOL

24分钟阅读

viewsIcon

11794

downloadIcon

112

使用 OBD II 端口和 Windows IoT 设备(DragonBoard 410c 或 Raspberry Pi)将实时引擎数据推送到 Azure Cloud 进行分析。

几周前,我把车送去做定期保养。车在店里的时候,我被告知变速箱和汽车 ECU 有固件更新可用,并被要求允许安装更新。我允许了更新,但这让我好奇,更新后汽车的行为是否真的有什么不同。主观上,我会说有,但这可能是确认偏差在作怪。这让我想到,如果我有关于汽车运行情况的客观数据,那么我也会有数据可以进行比较,并查看更新前/更新后的对比。现在对我的车进行这种比较已经太晚了,但我仍在思考如何收集这些数据。好吧,自 1996 年以来制造的每辆车都在仪表盘下方有一个连接器,可用于访问有关汽车运行情况的实时数据。我目前有一个消费设备连接到我的 ODB II 端口,但它收集的数据很少;它检测速度、刹车、加速度和平均速度。汽车通过车内各种传感器提供更多数据,包括燃油油位、发动机某些部位的温度、氧气读数等等。

这让我开始了一个项目,旨在制作一个可以记录数据的设备。我想要一个可以安静工作,几乎不需要我任何干预的设备;我想要一个可以插上电源,然后忘记它,让它自己完成工作的设备。这里编写的代码是我目前为止组装起来的解决方案。这将是一个持续进行的工作,因为我都是间歇性地进行项目。首先,最重要的是实现核心功能;我希望添加许多额外的功能,但鉴于时间有限,最好将它们搁置,专注于核心功能;获取引擎数据并将其保存到可以分析的地方。我宁愿不为了存档数据而更换存储设备,因此数据被发送到 Azure。一旦发送到 Azure,就可以对数据进行许多操作。它可以保存,可以分析。它可以路由到另一个设备进行实时读取。目前,我正在将数据路由到 Azure Data Lake。

关于解决所有这些问题的解决方案的几个部分,有一些问题需要提出。

  • 我如何与 ODB II 端口进行接口?
  • 我需要使用什么协议来集成汽车以获取数据?
  • 该解决方案将使用什么计算硬件?
  • 该解决方案需要什么软件/操作系统?
  • 如何将数据从设备中取出?

总的来说,这些问题的答案给出了解决方案的高级描述。让我们探讨一下每个问题。

如何与 ODB II 端口进行接口

根据我手头已有的一些硬件,我有两种解决方案可以与 ODB II 端口进行接口。我有一台带 Nvidia 处理器的单板计算机,它内置了一个专门用于与汽车通信的端口。除了需要有物理连接器将计算机连接到汽车的连接器之外,对此的硬件需求非常小。虽然我手头已经有硬件,但我希望该解决方案易于且廉价地复制。使用这台单板计算机不符合廉价的要求。我手头已有的另一种解决方案是 ODB II 配件,它通过蓝牙 RFCOMM 连接提供数据。这种适配器有通过 RS232 通信的版本。说实话,我更喜欢其中之一,因为与有线连接通信所需的代码更少。但我家里已经有蓝牙适配器了。所以就用蓝牙吧。

我使用的适配器灵感来源于 ELM327 芯片组。我说“灵感来源于”而不是“基于”,因为设备中的芯片组实际上并非来自 ELM。市面上有许多适配器声称是 ELM 芯片组,但实际上并非如此。当查询时,它们会自称为来自 ELM。根据您的倾向,这些可能被视为假冒设备或仿真/兼容设备。双方都有可以用来辩护的论点。但这并非我试图解决的争论,我分享这些只是为了让那些倾向于将此类做法视为假冒的人可以留意。我见过报道说,其他一些制造商的这些设备不适用于某些汽车。话虽如此,我不知道如何提前判断一个设备是否使用真正的 ELM 芯片或来自其他方的芯片。

这些设备有许多不同尺寸的变体。我使用的那个有点大。但我认为这可能是因为它比较老旧。根据你的 ODB 端口的位置,你可能需要确保购买一个更小的。否则,它可能会被驾驶员的膝盖撞出位置。我把任何可能掉落在驾驶员脚部区域的物体都视为危险品;你肯定不希望它掉出来卡在踏板下,阻止踏板完全被踩下。

ODB Reader Hardware

我用来为 OBD II 蓝牙适配器供电的适配器和线束。

与汽车通信需要什么协议?

这个问题的答案与第一个问题的答案相关。由于我使用的是 ELM327 *启发式*连接器,我将使用 ELM 所使用的协议。车辆可能使用多种协议来连接其 ODB 连接器。基于 ELM 的设备实现了其中几种协议,并允许通过 ELM 创建的单一协议访问它们的数据。该协议是基于文本的。它的简化描述是,我发送特定读数的 ID,适配器会响应该属性的值。大多数查询和响应都是十六进制响应。ELM 设备还支持许多 AT 调制解调器命令来更改设备的设置。该协议足够易于使用,可以开始使用笔记本电脑或手机和适配器进行一些查询。去吧,试试看。如果你使用的是 Android 手机,Blue Term 应用程序在这里很有用。如果你使用的是 PC,PuTTY 应用程序可以使用。

将您的 ODB 设备连接到您的汽车,并将您的计算机或手机(我从现在开始就称之为“计算机”)与之配对。在 PC 上,您需要在设备管理器中查找与该设备关联的 COM 端口。在 Android 设备上,如果您打开蓝牙设置,您会看到设备的名称。对于我的手机,该设备名为“ODBII”。无论哪种情况,打开您的终端软件并连接到该设备。在终端软件中,键入 AT 并按回车。如果一切正常,您将收到响应 OK。对于接受 AT 命令集的设备,大多数命令都将以 AT 字符串开头。单独的 AT 不执行任何操作,可用于测试连接而无需执行任何其他操作。

下一项测试的安全注意事项。我相信你们很多人已经知道一氧化碳是致命的。对于任何涉及汽车运行的测试,您都应该将汽车开出车库,放在外面。我在这里提醒您,因为我不希望任何人在兴奋中忘记,在关闭车库门的情况下在车库中运行汽车可能是致命的。

把车开到外面,安全的地方让引擎运转。在引擎运转时,输入 01 0C。汽车将响应一串以 41 0C 开头的十六进制数字。您使用的大多数命令都将以 01 开头。后面的 0C 是引擎转速。在返回的响应中,0C 是重复的请求属性。如果其后的十六进制数字转换为整数,您将得到引擎的转速。查询其余引擎读数只是了解属性的 ID 以及如何解释返回的数字的问题。在我的代码中,我有一个枚举,其中包含一些属性的 ID 和数值。我将这些值标记为 LiveProperty,因为这些是引擎实时当前状态的值。还有一些值是从事件触发汽车计算机拍摄的值快照中保存的,用于诊断期间的分析。我在此项目中完全不查看快照值。

public enum LiveProperty:int
{
    #region PidRange Queries
    PidRange_00_32 = 0x00,//00
    PidRange_33_64 = 0x20,//20
    PidRange_65_96 = 0x40,//40
    PidRange_97_128 = 0x60,//60
    PidRange_129_160 = 0x80,//80
    #endregion

    HeadersOn = 1,
    HeadersOff = 2,
    FuelSystemStatus = 0x03,
    EngineLoad = 0x04,
    EngineCoolantTemperature = 0x05,
    ShortTermFuelTrimBank1 = 0x06,
    LongTermFuelTrimBank1 = 0x07,
    ShortTermFuelTrimBank2 = 0x08,
    LongTermFuelTrimBank2 = 0x09,
    FuelPressure =  0x0A,
    //IntakeManifoldPressure, = 0x0B
    IntakeManifoldAbsolutePressure = 0x0B,
    EngineRPM = 0x0C,
    VehicleSpeed = 0x0D,
    TimingAdvance = 0x0E,
    AirIntakeTemperature = 0x0F,
    Airflow = 0x10,
    Throttle = 0x11,
    SecondaryIntakeCircuit = 0x12,//maybe?
    #region O2Sensor
    O2SensorVoltsBank1Sensor1 = 0x14,
    O2SensorVoltsBank1Sensor2 = 0x15,
    O2SensorVoltsBank1Sensor3 = 0x16,
    O2SensorVoltsBank1Sensor4 = 0x17,
    O2SensorVoltsBank2Sensor1 = 0x18,
    O2SensorVoltsBank2Sensor2 = 0x19,
    O2SensorVoltsBank2Sensor3 = 0x1A,
    O2SensorVoltsBank2Sensor4 = 0x1B,
    #endregion
};

解决方案需要什么计算硬件

虽然我已将 nVidia 设备排除在外,但仍有许多其他设备可以工作。Intel Edison 体积小巧(大约硬币大小),可以很好地完成这项工作。我家里还有许多 Raspberry Pi,一块 Arrow Dragon Board 401c 单板计算机,以及其他一些设备。我决定使用 Dragonboard 401c,主要是因为它内置了 GPS。但是,为自己实现此解决方案的人不必因为我的操作系统/软件决策而使用相同的硬件来使用我的相同解决方案。Raspberry Pi II 和 Raspberry Pi III 之间的主要区别是 Raspberry Pi II 内置了无线适配器。此解决方案最重要的适配器是蓝牙适配器,因为它用于与硬件通信。

该解决方案需要什么软件/操作系统

我决定在我的解决方案中使用 Windows 10 IOT。Windows 10 IOT 将在 Raspberry Pi II 及更高版本上运行。它也运行在 Dragonboard 401c 上。这就是为什么使用我的解决方案的人可以使用任何一个板。我正在开发的应用程序是一个 UWP 应用程序(通用 Windows 平台)。除了在这些设备上运行之外,它还可以在 PC 上运行。这很有帮助,因为它允许进行一定程度的调试,而无需直接连接到汽车。在开发此软件期间,我曾多次捕获端口的通信,并从桌面(或在乘坐火车上班时在笔记本电脑上)进行调试。

基于 UWP 的应用程序可以编译为在 ARM 硬件、x86 硬件或 x64 硬件上运行。

如何从设备中获取数据

一旦解决方案的设备接收到数据,仍然存在如何将其传输到外部世界的问题。有两种方法可以做到这一点:通过网络连接和通过存储设备。网络连接是我的主要解决方案,但我并未忽略存储设备。我已将解决方案设计为既可以写入存储设备,也可以将数据发送到 Azure。此解决方案旨在无头运行;无需显示器、键盘或鼠标。我必须事先考虑它将如何选择存储位置。应用程序启动时,它将查找连接到设备的外部存储设备。它将使用找到的第一个存储设备。如果没有存储设备,它将写入内部内存。Windows IOT 与您所知的 Windows 没有太大区别,因为它也有一个 *Documents* 文件夹。如果没有连接外部存储设备,这将是一个备用位置。如果数据写入 *Documents* 文件夹,则可以通过网络连接浏览它。这不是我想做的事情,但它适用于那些感兴趣的人。不过,我没有做任何处理设备内存已满的情况。如果您打算为自己使用此解决方案并且没有大量可用内存,那么这是一个您需要解决的问题。

将数据导入 Azure 非常简单。将要保存的数据放入 JSON string 后,我使用 Azure 客户端库发送 JSON 消息。我将逐步讲解代码,您会看到它是如何工作的。

实现

使用 Visual Studio 2017,创建一个新项目。项目类型将是空白的 UWP 项目。我将我的项目命名为 AutoTelemetry,这将在整个代码中体现。项目创建后,右键单击它并选择“新建文件夹”。创建一个名为“ViewModels”的文件夹。这里需要添加一些基础代码。如果您熟悉 MVVM 模式,那么这些代码将是基础的。MVVM 模式在此处不作解释,因为它已有大量可用信息。右键单击 ViewModels 文件夹,然后选择 添加新建项。对于项的类型,选择 并将项命名为 ViewModelBase.csViewModelBase 的内容如下

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using Windows.UI.Core;

namespace AutoTelemetry.ViewModels
{
    public abstract class ViewModelBase : INotifyPropertyChanged
    {
        public CoreDispatcher Dispatcher { get; set; }

        protected void OnPropertyChanged<T>(Expression<Func<T>> expression)
        {
            OnPropertyChanged(((MemberExpression)expression.Body).Member.Name);   
        }

        void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
            {
                if (this.Dispatcher != null)
                {
                    Dispatcher.RunAsync(
                                      CoreDispatcherPriority.Normal,
                                      () =>
                                      {
                                          PropertyChanged(this, 
                                          new PropertyChangedEventArgs(propertyName));
                                      });
                }
                else
                    PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
            }
        }

        protected bool SetValueIfChanged<T>(Expression<Func<T>> propertyExpression, 
                       Expression<Func<T>> fieldExpression, object value)
        {
            var property = (PropertyInfo)((MemberExpression)propertyExpression.Body).Member;
            var field = (FieldInfo)((MemberExpression)fieldExpression.Body).Member;
            return SetValueIfChanged(property,field, value);
        }

        protected bool SetValueIfChanged(PropertyInfo pi,FieldInfo fi, object value)
        {
            var currentValue = pi.GetValue(this);
            if ((currentValue == null && value == null)||
                (currentValue!=null &&  currentValue.Equals(value)))
                return false;
            fi.SetValue(this, value);
            OnPropertyChanged(pi.Name);
            return true;
        }
        public event PropertyChangedEventHandler PropertyChanged;
    }
}

在某些方面,这个类可能不符合常规。其一是在类中存在一个 CoreDispatcher。我将其放在这里,因为有些类会从其他线程修改,并且更改事件必须在主线程上引发。SetValueIfChanged 方法可能看起来最不熟悉。我厌倦了重复输入代码模式,所以我实现了一些东西来缩短代码。我已经在另一篇文章中写过它是如何开发的。如需解释,您可以阅读这篇文章。

添加另一个名为 EngineState 的类。该类具有许多属性并继承自 ViewModelBase。其上的属性都使用相同的模式实现。以下是其中一些属性的实现,以展示这些模式。

int? _rpm;
public int? RPM
{
    get { return _rpm;  }
    set
    {
        SetValueIfChanged(() => RPM, () => _rpm, value);
        ResetLastUpdated();
    }
}

int? _throttle;
public int? Throttle
{
    get { return _throttle;  }
    set
    {
        SetValueIfChanged(() => Throttle, ()=>_throttle, value);
        ResetLastUpdated();
    }
}

int? _vehicleSpeed;
public int? VehicleSpeed
{
    get { return _vehicleSpeed;  }
    set
    {
        SetValueIfChanged(() => VehicleSpeed, () => _vehicleSpeed, value);
        ResetLastUpdated();
    }
}

添加另一个名为 MainViewModel 的类。这个类也应该继承自 ViewModelBase。我们将在此类中添加大部分代码。

namespace AutoTelemetry.ViewModels
{
    public class MainViewModel : ViewModelBase
    {
    	// implementation to come later
    }
}

微软基于 XAML 的 UI 技术中另一个著名的类集是 DelegateCommand 类。这些类可以在其他一些工具包中找到定义。但由于这些是工具包中我将使用的唯一类,我已将这些类包含在此处,而不是引用工具包。关于这个类也有大量可用信息。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Input;

namespace AutoTelemetry.ViewModels
{
    public class DelegateCommand : ICommand
    {
        private readonly Action _execute;
        private readonly Func<bool> _canExecute;

        public DelegateCommand(Action execute)
            : this(execute, null)
        {
        }

        public DelegateCommand(Action execute, Func<bool> canExecute)
        {
            if ((_execute = execute) == null)
                throw new ArgumentNullException("execute");

            _canExecute = canExecute;
        }

        public bool CanExecute(object parameter)
        {
            if (_canExecute == null)
                return true;

            return _canExecute();
        }

        public void Execute()
        {
            if (CanExecute(null))
                _execute();
        }

        void ICommand.Execute(object parameter)
        {
            Execute();
        }

        public event EventHandler CanExecuteChanged;

        public void RaiseCanExecuteChanged()
        {
            if (CanExecuteChanged != null)
                CanExecuteChanged(this, EventArgs.Empty);
        }
    }

    public class DelegateCommand<T> : ICommand
    {
        private readonly Action<T> _execute;
        private readonly Func<T, bool> _canExecute;

        public DelegateCommand(Action<T> execute)
            : this(execute, null)
        {
        }

        public DelegateCommand(Action<T> execute, Func<T, bool> canExecute)
        {
            if ((_execute = execute) == null)
                throw new ArgumentNullException("execute");

            _canExecute = canExecute;
        }

        public bool CanExecute(object parameter)
        {
            if (_canExecute == null)
                return true;

            return _canExecute((T)parameter);
        }

        public void Execute(T parameter)
        {
            if (CanExecute(parameter))
                _execute(parameter);
        }

        void ICommand.Execute(object parameter)
        {
            Execute((T)parameter);
        }

        public event EventHandler CanExecuteChanged;

        public void RaiseCanExecuteChanged()
        {
            if (CanExecuteChanged != null)
                CanExecuteChanged(this, EventArgs.Empty);
        }
    }
}

现在是时候深入了解解决方案的代码了。在 UWP 应用程序中,需要进行能力声明才能访问某些硬件和功能。这背后的部分考虑是为了在应用商店发布的应用程序的安全。用户在安装应用程序之前可以看到应用程序请求访问机器的哪些功能,从而在是否安装应用程序方面做出更明智的决定;如果您看到一个计算小费的应用程序也需要访问您的联系人,那么可能有一些可疑的事情正在发生。虽然此应用程序不会在应用商店中出现,但它仍然受某些相同规则的约束。为了访问 Internet 和蓝牙适配器,需要进行声明。没有这些声明,您会遇到一些奇怪的错误,这可能会令人困惑。与其让您接触这些错误,不如我现在引导您完成必要的声明。Visual Studio 有一个处理声明的 UI。尽管它很有用,但我发现它没有公开声明访问 RFCOMM 所需的内容。

右键单击项目中名为 Package.appxmanifest 的文件,然后选择 查看代码。一个 XML 文档会打开。文档中将有一个带有 <Capabilities> 元素的节。它已经定义了一个 internet 能力。修改此节,使其看起来像以下内容

  <Capabilities>
    <Capability Name="internetClient" />
    <DeviceCapability Name="bluetooth" />
    <DeviceCapability Name="proximity" />
    <DeviceCapability Name="location" />
    <DeviceCapability Name="bluetooth.rfcomm">
      <Device Id="any">
        <Function Type="name:serialPort" />
      </Device>
    </DeviceCapability>
  </Capabilities>

这将使应用程序能够访问蓝牙 RFCOMM 和位置信息。代码需要扫描计算机上的可用设备,以找到连接到汽车的设备。我在这里硬编码了一部分知识;我知道该设备名为 ODBII。虽然我从扫描中收到了许多设备,但我选择了名为 ODBII 的设备并继续使用它。没错。

string[] requestedProperties = new string[] 
{ "System.Devices.Aep.DeviceAddress", "System.Devices.Aep.IsConnected" };
_deviceWatcher = DeviceInformation.CreateWatcher
("(System.Devices.Aep.ProtocolId:=\"{e0cbf06c-cd8b-4647-bb8a-263b43f0f974}\")",
                                               requestedProperties,
                                               DeviceInformationKind.AssociationEndpoint);
_deviceWatcher.Stopped += (sender,x)=> {
    _isScanning = false;
    Log("Device Scan Halted");
};
EngineDataList.Add("started");
_deviceWatcher.Added += async (sender, devInfo) =>
{
    if (devInfo.Name.Equals("OBDII"))
    {
        
        DeviceAccessStatus accessStatus = 
            DeviceAccessInformation.CreateFromId(devInfo.Id).CurrentStatus;
        if (accessStatus == DeviceAccessStatus.DeniedByUser)
        {
            Debug.WriteLine("This app does not have access to connect to the 
              remote device (please grant access in Settings > Privacy > Other Devices");
            return;
        }
        var device = await BluetoothDevice.FromIdAsync(devInfo.Id);
        //Other code to respond to the discovered hardware goes here
    }

}

这可行,但有一个主要问题。它太慢了!如果你在一个应用程序中运行它几分钟后,它最终会找到蓝牙硬件。此扫描所需的唯一信息是蓝牙适配器的硬件 ID。我不是每次成功连接到设备后都扫描此 ID,而是保存设备的 ID。我正在做的事情的伪代码如下所示

IF(SavedDeviceIDFound)
	var connected = TryConnectToDevice(DeviceID);
	if(connected) return;
END IF;
DeviceID = SearchForDeviceID();
var connected = TryConnectToDevice(DeviceID)
IF(connected)
	SaveDeviceID(DeviceID);
END IF;

我利用 LocalSettings API 保存设备 ID。您可以在 CodeProject 上此处阅读更多关于其工作原理的信息。连接到设备后,我获取到设备的数据流,构建数据读取器和数据写入器以与设备进行交互。数据读取器和写入器彼此独立使用。

DataReader _receiver;
DataWriter _transmitter;
StreamSocket _stream;

async Task ConnectToDevice(string deviceId)
{
    var device = await BluetoothDevice.FromIdAsync(deviceId);

    Debug.WriteLine(device.ClassOfDevice);

    var services = await device.GetRfcommServicesAsync();
    if (services.Services.Count > 0)
    {
        Log("Connecting to device stream");
        var service = services.Services[0];
        _stream = new StreamSocket();
        await _stream.ConnectAsync(service.ConnectionHostName,
        service.ConnectionServiceName);
        _receiver = new DataReader(_stream.InputStream);
        _transmitter = new DataWriter(_stream.OutputStream);

        //These following three methods kick off processes that
        //run on their own threads. 
        ReceiveLoop();
        QueryLoop();
        StatusUpdateLoop();
        await this.Dispatcher.RunAsync(
           Windows.UI.Core.CoreDispatcherPriority.Normal,
           () =>
           {
               IsConnected = true;
               //Send any initialization messages here
           });
    }
}

连接方法的末尾调用的三个方法会启动新线程。一个线程发送消息以请求各种读数(QueryLoop())。它的操作是相当独立的,并且对其他正在发生的事情是盲目的。另一个线程用于处理传入数据并将其传递给解析器(ReceiveLoop())。设备每秒可能查询信息几次。但我希望以较低的采样率保存引擎状态。StatusUpdateLoop() 将以一定的频率获取当前读数的累积,并将其存储起来。

接收数据时,无法保证可供读取的数据是完整消息。有必要对传入的数据进行缓冲,然后在已知数据是完整响应后对其进行处理。完整响应由换行符分隔。一旦检测到换行符,缓冲的数据将被处理,并且缓冲区将被清除,以便数据累积可以继续。

StringBuilder receiveBuffer = new StringBuilder();
void ReceiveLoop()
{
    Task t = Task.Run(async () => {
        Log("Starting listening loop");
        while (true)
        {
            uint buf;
            buf = await _receiver.LoadAsync(1);
            if (_receiver.UnconsumedBufferLength > 0)
            {
                string s = _receiver.ReadString(1);
                receiveBuffer.Append(s);
                if (s.Equals("\n")||s.Equals("\r"))
                {
                    try
                    {
                        ProcessData(receiveBuffer.ToString());
                        receiveBuffer.Clear();
                    }
                    catch(Exception exc)
                    {
                        Log(exc.Message);
                             
                    }
                }
            }else
            {
                await Task.Delay(TimeSpan.FromSeconds(0));
            }
        }
    });
}

实现数据处理方法的一种自然方法是弄清楚正在发送的数据类型,然后使用一个大型 switch 语句或 if/then 列表将接收到的数据分配给数据对象上的正确属性。但这对于相对简单的事情来说代码太多了。我采取了一种重复性较少的方法。相反,在将十六进制字符串转换为字节数组后,我利用了整数和枚举值之间的简单转换。我还拥有枚举值及其关联属性的字典映射。如果必须解析其他属性,我只需要确保它有一个 Engine 属性、一个 enum 定义以及属性和值的映射。

 _livePropertyMappings = new Dictionary<liveproperty, propertyinfo="">();
_livePropertyMappings.Add(LiveProperty.EngineRPM,                
typeof(EngineState).GetProperty(nameof(EngineState.RPM)));
_livePropertyMappings.Add(LiveProperty.Throttle,                 
typeof(EngineState).GetProperty(nameof(EngineState.Throttle)));
_livePropertyMappings.Add(LiveProperty.VehicleSpeed,             
typeof(EngineState).GetProperty(nameof(EngineState.VehicleSpeed)));
_livePropertyMappings.Add(LiveProperty.EngineCoolantTemperature, 
typeof(EngineState).GetProperty(nameof(EngineState.EngineCoolantTemperature)));

回到处理传入数据流,将十六进制字符串转换为字节数组后的数据处理方法很简单。

void ProcessEngineDataMessage(byte[] message)
{
    if (message[0] == 0x41)
    {
        LiveProperty prop = (LiveProperty)message[1];
        if (_livePropertyMappings.ContainsKey(prop))
        {
            int val = GetInt(message, 2);
            Log($"{prop} = {val}");
            _livePropertyMappings[prop].SetValue(this.Engine, val);
        }
    }
}

查询数据也很简单。我创建了一个包含要查询的属性的数组,并循环遍历它们,一次发送一个属性的查询消息。

void QueryLoop()
{
    Task t = Task.Run(async () =>
    {
        LiveProperty[] propertyList =
        {
            LiveProperty.EngineRPM,
            LiveProperty.Throttle,
            LiveProperty.VehicleSpeed,
            LiveProperty.EngineCoolantTemperature
        };
        int count = 0;
        while (true)
        {
            QueryPropertyCommand.Execute(propertyList[(++count)%propertyList.Length]);
            await Task.Delay(50);
        }
    }
    );
}

我尚未定义的最后一个循环是 StatusUpdateLoop()EngineState 类有一个名为 LastUpdated 的属性,它是类实例中值最后一次更新的时间戳。StatusUpdateLoop 每 5 秒检查一次该类。它检查以确保当前对象状态的时间戳比上次读取对象状态的时间更近。如果是,则获取对象状态并将其发送到 Azure。

void StatusUpdateLoop()
{
    Task t = Task.Run(async () =>
    {
        while(true)
        {
            if(Engine.LastUpdated > _lastUpdate)
            {
                EngineState update;
                lock(SyncRoot)
                {
                    update = this.Engine;
                    this.Engine = new EngineState() { VIN = update.VIN, 
                    LastUpdated = _lastUpdate = DateTime.Now, Location = this.LastPosition };
                }
                try
                {
                    this.Dispatcher.RunAsync(
                        CoreDispatcherPriority.Normal,
                        () =>
                        {
                            this._engineLog.Add(update);
                        });
                    Message iotMessage = new Message(Encoding.UTF8.GetBytes(update.ToString()));
                    await IotDeviceClient.SendEventAsync(iotMessage);
                }
                catch (Exception exc) {
                    Log(exc.Message);
                }
            }
            await Task.Delay(5000);
        }
    });
}

UI 仪表盘

我打算让这个应用程序以无头应用程序运行。但我正在添加 UI 元素。这有什么用?它提供调试信息。我本想添加图形仪表盘,但不想花太多时间自己实现。有一个 UWP 工具包包含仪表盘,但我遇到了让它们工作的问题。我最终使用了 Telerik UI 工具包。它包含径向和线性仪表盘(它易于使用!)。这些仪表盘内置动画支持。在添加对 Telerik 库的引用并将控件放入页面后,可以通过 XAML 配置控件,并通过 XAML 数据绑定设置值。管理仪表盘的唯一代码是它们的声明。我不是试图通过 UI 公开所有值。只有少数几个也在我汽车的内置仪表盘上显示:转速、速度和燃油油位。

<telerik:RadRadialGauge 
                        x:Name="Speedometer"
                        Grid.Row="1" 
                        Grid.RowSpan="2"
                        Background="Gray"
                        HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
                        MinValue="0" MaxValue="120" 
                        MinAngle="-45" MaxAngle="225"  
                        
                        LabelRadiusScale="0.8" 
                        TickRadiusScale="0.85"
                        TickStep="10" 
                        LabelStep="20"                                >

    <telerik:RadRadialGauge.LabelTemplate>
        <DataTemplate>
            <TextBlock Text="{Binding}" FontSize="20" FontWeight="Bold" 
             Foreground="#595959" Margin="0,0,0,10"></TextBlock>
        </DataTemplate>
    </telerik:RadRadialGauge.LabelTemplate>

    <telerik:SegmentedRadialGaugeIndicator StartValue="0" 
     Value="{Binding Engine.VehicleSpeed}"  telerik:RadRadialGauge.IndicatorRadiusScale="0.73">
        <telerik:BarIndicatorSegment Thickness="20" Stroke="#8080FF" Length="80"/>
        <telerik:BarIndicatorSegment Thickness="20" Stroke="Blue" Length="0"/>
        <telerik:BarIndicatorSegment Thickness="20" Stroke="#000080" Length="2"/>
    </telerik:SegmentedRadialGaugeIndicator>

    <telerik:MarkerGaugeIndicator Value="70" Content="*"  FontSize="17" 
     Foreground="#595959" telerik:RadRadialGauge.IndicatorRadiusScale="0.83"/>
</telerik:RadRadialGauge>
<TextBlock Grid.Row="1" Grid.RowSpan="2" VerticalAlignment="Center" 
 HorizontalAlignment="Center" >Speed</TextBlock>

<telerik:RadRadialGauge 
                        x:Name="FuelLevelMeter"
                        Grid.Column="1"
                        Grid.Row="1" 
                        MinValue="0" MaxValue="100" 
                        MinAngle="-45" MaxAngle="225"  
                        LabelRadiusScale="0.9" 
                        TickStep="20" 
                        LabelStep="20"                                >
    <telerik:SegmentedRadialGaugeIndicator StartValue="0" 
     Value="{Binding Engine.FuelLevel}"  telerik:RadRadialGauge.IndicatorRadiusScale="0.73">
        <telerik:BarIndicatorSegment Thickness="20" Stroke="Orange" Length="80"/>
        <telerik:BarIndicatorSegment Thickness="20" Stroke="Blue" Length="0"/>
        <telerik:BarIndicatorSegment Thickness="20" Stroke="Black" Length="2"/>
    </telerik:SegmentedRadialGaugeIndicator>
</telerik:RadRadialGauge>
<TextBlock Grid.Column="1" Grid.Row="1" VerticalAlignment="Center" 
 HorizontalAlignment="Center"  Text="Fuel"/>


<telerik:RadRadialGauge 
                        x:Name="RPMMeter"
                        Grid.Column="1"
                        Grid.Row="2" 
                        MinValue="0" MaxValue="7000" 
                        MinAngle="-45" MaxAngle="225"  
                        LabelRadiusScale="0.9" 
                        TickRadiusScale="0.85"
                        TickStep="1000" 
                        LabelStep="1000"                                >
    <telerik:SegmentedRadialGaugeIndicator StartValue="0" Value="{Binding Engine.RPM}" 
     telerik:RadRadialGauge.IndicatorRadiusScale="0.73">
        <telerik:BarIndicatorSegment Thickness="20" Stroke="Purple" Length="80"/>
        <telerik:BarIndicatorSegment Thickness="20" Stroke="Yellow" Length="0"/>
        <telerik:BarIndicatorSegment Thickness="20" Stroke="Green" Length="2"/>
    </telerik:SegmentedRadialGaugeIndicator>
</telerik:RadRadialGauge>
<TextBlock Grid.Column="1" Grid.Row="2" VerticalAlignment="Center" 
 HorizontalAlignment="Center"  Text="RPM"/>

Azure 设置

我正在使用 Azure IOT Hub 从设备获取信息。我已经有一个 Azure 帐户。免费帐户可以用于测试。在这里,我不谈论设置新帐户的步骤。但如果你访问Azure 网站,你会看到获取免费帐户的说明。一旦进入帐户,可用的选项和操作数量可能令人望而生畏。我不会探索所有这些选项,只会专注于所需的内容。对于这个项目,我想设置一个资源组,我使用的其他资源将存在于其中。这并非绝对必要,但它有助于更轻松地组织。

登录帐户后,在左侧菜单中,单击标题为 资源组 的项目。当资源组屏幕打开时,单击 添加。输入资源组的名称并选择“创建”。

必须创建 Windows Azure IoT Hub 资源。在左侧菜单中,选择 创建资源。在打开的屏幕中,在搜索窗口中键入 IoT 以缩小选项范围。选择 IoT Hub 并单击 创建。在打开的屏幕中,选择使用现有资源组的选项。选择您已创建的资源组,并为 IoT Hub 实例命名,然后选择 审查+创建。在下一个屏幕中,检查您的输入并选择 创建。IoT 资源的创建将需要几分钟。如果您单击通知图标(铃铛状),您可以查看创建过程的进度。

资源创建完成后,您可以通过从左侧菜单中选择 所有资源,然后选择出现在屏幕上的刚刚创建的资源来访问它。我们需要为 IoT 设备创建一个记录(设备是唯一标识的)。选择新创建的 IoT 中心实例后,单击菜单中的 IoT 设备,然后选择 添加。为设备命名并选择 保存。设备创建后,您可以在 IoT 设备 屏幕中单击它以获取特定设置,例如设备的连接字符串。

要创建的下一个资源是存储传入数据的位置。单击 创建资源,然后从可创建的资源列表中选择 Data Lake Storage Gen1。请记住,您可以通过在顶部的窗口中键入资源的名称来筛选选项。再次为资源组,选择我们创建的现有组,并为数据湖存储命名,然后选择 创建。同样,创建新资源将需要几分钟。

创建完成后,我们有了一个设备的记录和与真实(物理)设备共享的凭据。点击设备,我看到设备特定的连接字符串。目前,我将该 string 嵌入到代码中。

DeviceClient _iotDeviceClient;
DeviceClient IotDeviceClient
{
    get
    {
        return _iotDeviceClient ?? (_iotDeviceClient = 
        DeviceClient.CreateFromConnectionString(DeviceConnectionString, TransportType.Http1));
    }
}    

我们有一个存储位置,可以将数据放入其中。但我们还没有一种方法将数据从设备传输到存储。为此,我们需要一个 Stream Analytics 作业。使用 创建资源 选项创建一个新作业。

作业创建后,导航到它;在使用它之前,需要更改其他属性。单击 输入 设置。对于输入,选择 添加流输入 并选择 IoT Hub 作为输入源。系统会提示您输入一个别名作为输入名称。输入名称并确保选择 JSON 作为序列化格式。

还必须定义一个输出。点击 输出 菜单项并选择 创建。您将被问及您正在使用哪种类型的输出。选择 Azure Data Lake。给输出一个名称。在 路径前缀模式 下,必须输入一个模式以了解保存日志的文件夹结构。我使用的是路径 engine/ecu/{date}。当有数据需要处理时,{date} 将被替换为当前日期。由于我为 日期格式 所做的选择,将有一个年份文件夹、一个月份文件夹和一个日期文件夹。我已将输出格式设置为 JSON。选择所有这些后,我点击 授权,等待片刻,然后点击 保存

要完成 Stream Analytics 作业,我们需要添加一个查询。由于目前不会对数据进行任何转换,因此查询只需允许数据通过即可。此处显示的默认查询即可。

至此,我们已经有一个 Azure 配置,足以将引擎数据从汽车流式传输到数据湖进行分析和收集。请注意,通过我们配置的任何数据都将暂时保存。它将保留几天,然后才会被删除。如果我们要保留数据存档,这足以获取数据并将其移动到长期存储。

让我们回顾一下状态循环更新中的一些代码。

var update = this.Engine;
this.Engine = new EngineState() { VIN = update.VIN, LastUpdated = _lastUpdate = DateTime.Now, 
Location = this.LastPosition, Dispatcher=this.Dispatcher };
	try
{
    Message iotMessage = new Message(Encoding.UTF8.GetBytes(update.ToString()));
    await IotDeviceClient.SendEventAsync(iotMessage);
}
catch (Exception exc) {
    Log(exc.Message);
}

上述代码获取包含引擎数据的当前对象实例,并创建一个新实例以收集其他数据。请注意,新实例被赋予一个调度器;它从另一个线程更新,并且调度器用于将 INotifyPropertyChanged 事件发送回 UI 线程。对于刚刚获取的实例,我将其转换为 JSON(对象的 ToString() 方法已被重写为输出 JavaScript 格式),并将其封装在一个 Azure Message 对象中。然后,使用 Azure 客户端工具包中的 DeviceClient.SendEventAsync 将消息发送到 Azure 云。

运行代码

项目已准备好运行。对于互联网连接,我在车里有一个专用的便携式蜂窝热点。我把它带到家里,在它连接到屏幕时与我的 Windows IoT 硬件配对。使用 Microsoft IoT Dashboard,我打开了设备的设置,并将我的应用程序设置为默认应用程序。现在,当设备通电时,我的应用程序将自动启动,连接到 ODB II 适配器,并开始收集数据以发送到云端。

好的,你正在收集数据,那又怎样?

我可以扩展我所拥有的解决方案的方向有很多。我项目此阶段的主要目标是收集数据进行分析。最初的下一步是建立一个定期下载和存档引擎数据的机制。如果您正在使用 Raspberry Pi 3B,一个值得考虑的解决方案是 PiJuice。PiJuice 尚未正式支持 Windows 10 IoT,但源代码是公开的,乍一看,它似乎易于使用。PiJuice 包含一个电池,可在汽车关闭时保持设备开启。它内置了实时时钟,并允许安排闹钟以唤醒设备。还可以添加其他传感器,例如加速度计,以很好地补充传感器数据。

 

历史

  • 2019 年 8 月 8 日:首次发布
© . All rights reserved.