diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1648594
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,215 @@
+# EditorConfig is awesome:http://EditorConfig.org
+# https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference
+
+# top-most EditorConfig file
+root = true
+
+# Don't use tabs for indentation.
+[*]
+indent_style = space
+# (Please don't specify an indent_size here; that has too many unintended consequences.)
+
+# Code files
+[*.{cs,csx,vb,vbx}]
+indent_size = 4
+insert_final_newline = false
+charset = utf-8-bom
+end_of_line = crlf
+
+# Xml project files
+[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
+indent_size = 2
+
+# Xml config files
+[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
+indent_size = 2
+
+# JSON files
+[*.json]
+indent_size = 2
+
+# Dotnet code style settings:
+[*.{cs,vb}]
+# Sort using and Import directives with System.* appearing first
+dotnet_sort_system_directives_first = true
+
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_indent_labels = flush_left
+
+#csharp_space_after_cast = true
+#csharp_space_after_keywords_in_control_flow_statements = true
+#csharp_space_between_method_declaration_parameter_list_parentheses = true
+#csharp_space_between_method_call_parameter_list_parentheses = true
+#csharp_space_between_parentheses = control_flow_statements, type_casts
+
+# 单行放置代码
+csharp_preserve_single_line_statements = true
+csharp_preserve_single_line_blocks = true
+
+# Avoid "this." and "Me." if not necessary
+dotnet_style_qualification_for_field = false:warning
+dotnet_style_qualification_for_property = false:warning
+dotnet_style_qualification_for_method = false:warning
+dotnet_style_qualification_for_event = false:warning
+
+# Use language keywords instead of framework type names for type references
+dotnet_style_predefined_type_for_locals_parameters_members = false:suggestion
+dotnet_style_predefined_type_for_member_access = false:suggestion
+#dotnet_style_require_accessibility_modifiers = for_non_interface_members:none/always:suggestion
+
+# Suggest more modern language features when available
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
+
+# CSharp code style settings:
+[*.cs]
+# Prefer "var" everywhere
+csharp_style_var_for_built_in_types = true:warning
+csharp_style_var_when_type_is_apparent = true:warning
+csharp_style_var_elsewhere = true:warning
+
+# Prefer method-like constructs to have a block body
+csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
+csharp_style_expression_bodied_operators = when_on_single_line:suggestion
+
+# Prefer property-like constructs to have an expression-body
+csharp_style_expression_bodied_properties = true:suggestion
+csharp_style_expression_bodied_indexers = true:suggestion
+#csharp_style_expression_bodied_accessors = true:suggestion
+
+# Suggest more modern language features when available
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_pattern_local_over_anonymous_function = true:suggestion
+
+csharp_style_throw_expression = true:suggestion
+csharp_style_conditional_delegate_call = true:suggestion
+
+# 单行不需要大括号
+csharp_prefer_braces = false:suggestion
+
+# Newline settings
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
+csharp_using_directive_placement = outside_namespace:silent
+csharp_style_expression_bodied_accessors = true:silent
+csharp_style_expression_bodied_lambdas = true:silent
+csharp_style_expression_bodied_local_functions = false:silent
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_style_namespace_declarations = block_scoped:silent
+
+[*.md]
+trim_trailing_whitespace = false
+[*.cs]
+#### 命名样式 ####
+
+# 命名规则
+
+dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
+dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
+dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
+
+dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+
+# 符号规范
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+# 命名样式
+
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+csharp_prefer_static_local_function = true:suggestion
+
+[*.vb]
+#### 命名样式 ####
+
+# 命名规则
+
+dotnet_naming_rule.interface_should_be_以_i_开始.severity = suggestion
+dotnet_naming_rule.interface_should_be_以_i_开始.symbols = interface
+dotnet_naming_rule.interface_should_be_以_i_开始.style = 以_i_开始
+
+dotnet_naming_rule.类型_should_be_帕斯卡拼写法.severity = suggestion
+dotnet_naming_rule.类型_should_be_帕斯卡拼写法.symbols = 类型
+dotnet_naming_rule.类型_should_be_帕斯卡拼写法.style = 帕斯卡拼写法
+
+dotnet_naming_rule.非字段成员_should_be_帕斯卡拼写法.severity = suggestion
+dotnet_naming_rule.非字段成员_should_be_帕斯卡拼写法.symbols = 非字段成员
+dotnet_naming_rule.非字段成员_should_be_帕斯卡拼写法.style = 帕斯卡拼写法
+
+# 符号规范
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.类型.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.类型.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected
+dotnet_naming_symbols.类型.required_modifiers =
+
+dotnet_naming_symbols.非字段成员.applicable_kinds = property, event, method
+dotnet_naming_symbols.非字段成员.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected
+dotnet_naming_symbols.非字段成员.required_modifiers =
+
+# 命名样式
+
+dotnet_naming_style.以_i_开始.required_prefix = I
+dotnet_naming_style.以_i_开始.required_suffix =
+dotnet_naming_style.以_i_开始.word_separator =
+dotnet_naming_style.以_i_开始.capitalization = pascal_case
+
+dotnet_naming_style.帕斯卡拼写法.required_prefix =
+dotnet_naming_style.帕斯卡拼写法.required_suffix =
+dotnet_naming_style.帕斯卡拼写法.word_separator =
+dotnet_naming_style.帕斯卡拼写法.capitalization = pascal_case
+
+dotnet_naming_style.帕斯卡拼写法.required_prefix =
+dotnet_naming_style.帕斯卡拼写法.required_suffix =
+dotnet_naming_style.帕斯卡拼写法.word_separator =
+dotnet_naming_style.帕斯卡拼写法.capitalization = pascal_case
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..781ca98
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,24 @@
+name: test
+
+on:
+ push:
+ branches: [ '*' ]
+ pull_request:
+ branches: [ '*' ]
+ workflow_dispatch:
+
+jobs:
+ build-test:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v2
+ with:
+ dotnet-version: 6.0.x
+ - name: Build
+ run: dotnet build -c Release
+ - name: Test
+ run: dotnet test -c Release
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..88ac376
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,25 @@
+################################################################################
+# 此 .gitignore 文件已由 Microsoft(R) Visual Studio 自动创建。
+################################################################################
+
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+build/
+bld/
+[Bb]in/
+[Oo]bj/
+/.vs/
+/Log
+/packages
+/Data
+Content/
+Config/
+*.log
+*.user
+/Test
+/IoTData/Properties/PublishProfiles/FolderProfile.pubxml
+/IoTData/.config/dotnet-tools.json
diff --git "a/Doc/IoTEdge\346\236\266\346\236\204.eddx" "b/Doc/IoTEdge\346\236\266\346\236\204.eddx"
new file mode 100644
index 0000000..5346117
Binary files /dev/null and "b/Doc/IoTEdge\346\236\266\346\236\204.eddx" differ
diff --git "a/Doc/IoT\345\244\247\346\225\260\346\215\256\345\255\230\345\202\250.eddx" "b/Doc/IoT\345\244\247\346\225\260\346\215\256\345\255\230\345\202\250.eddx"
new file mode 100644
index 0000000..f3d1b66
Binary files /dev/null and "b/Doc/IoT\345\244\247\346\225\260\346\215\256\345\255\230\345\202\250.eddx" differ
diff --git a/IoT.Data/Entity/Model.xml b/IoT.Data/Entity/Model.xml
new file mode 100644
index 0000000..76d3167
--- /dev/null
+++ b/IoT.Data/Entity/Model.xml
@@ -0,0 +1,108 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Tables xmlns:xs="http://www.w3.org/2001/XMLSchema-instance" xs:schemaLocation="https://newlifex.com https://newlifex.com/Model2022.xsd" NameSpace="IoT.Data" ConnName="IoT" Output="" BaseClass="Entity" Version="11.3.2022.1013" Document="https://newlifex.com/xcode/model" DisplayName="" CubeOutput="" xmlns="https://newlifex.com/Model2022.xsd">
+ <Table Name="Product" Description="产品。设备的集合,通常指一组具有相同功能的设备。物联网平台为每个产品颁发全局唯一的ProductKey。">
+ <Columns>
+ <Column Name="Id" DataType="Int32" Identity="True" PrimaryKey="True" Description="编号" />
+ <Column Name="Name" DataType="String" Master="True" Description="名称" />
+ <Column Name="Code" DataType="String" Description="编码。ProductKey" />
+ <Column Name="Enable" DataType="Boolean" Description="启用。开发中/已发布" />
+ <Column Name="DeviceCount" DataType="Int32" Description="设备数量" />
+ <Column Name="CreateUser" DataType="String" Description="创建人" Model="False" Category="扩展" />
+ <Column Name="CreateUserId" DataType="Int32" Description="创建者" Model="False" Category="扩展" />
+ <Column Name="CreateTime" DataType="DateTime" Description="创建时间" Model="False" Category="扩展" />
+ <Column Name="CreateIP" DataType="String" Description="创建地址" Model="False" Category="扩展" />
+ <Column Name="UpdateUser" DataType="String" Description="更新人" Model="False" Category="扩展" />
+ <Column Name="UpdateUserId" DataType="Int32" Description="更新者" Model="False" Category="扩展" />
+ <Column Name="UpdateTime" DataType="DateTime" Description="更新时间" Model="False" Category="扩展" />
+ <Column Name="UpdateIP" DataType="String" Description="更新地址" Model="False" Category="扩展" />
+ <Column Name="Remark" DataType="String" Length="500" Description="描述" Category="扩展" />
+ </Columns>
+ <Indexes>
+ <Index Columns="Code" Unique="True" />
+ </Indexes>
+ </Table>
+ <Table Name="Device" Description="设备。归属于某个产品下的具体设备。物联网平台为设备颁发产品内唯一的证书DeviceName。设备可以直接连接物联网平台,也可以作为子设备通过网关连接物联网平台。">
+ <Columns>
+ <Column Name="Id" DataType="Int32" Identity="True" PrimaryKey="True" Description="编号" />
+ <Column Name="Name" DataType="String" Master="True" Description="名称" />
+ <Column Name="Code" DataType="String" Description="编码。设备唯一证书DeviceName,用于设备认证,在注册时由系统生成" />
+ <Column Name="ProductId" DataType="Int32" Description="产品" />
+ <Column Name="Enable" DataType="Boolean" Description="启用" />
+ <Column Name="Online" DataType="Boolean" Description="在线" />
+ <Column Name="Uuid" DataType="String" Length="200" Description="唯一标识。硬件标识,或其它能够唯一区分设备的标记" />
+ <Column Name="Location" DataType="String" Description="位置。场地安装位置,或者经纬度" Category="登录信息" />
+ <Column Name="PollingTime" DataType="Int32" Description="采集间隔。默认1000ms" Category="参数设置" />
+ <Column Name="CreateUserId" DataType="Int32" Description="创建者" Model="False" Category="扩展" />
+ <Column Name="CreateTime" DataType="DateTime" Description="创建时间" Model="False" Category="扩展" />
+ <Column Name="CreateIP" DataType="String" Description="创建地址" Model="False" Category="扩展" />
+ <Column Name="UpdateUserId" DataType="Int32" Description="更新者" Model="False" Category="扩展" />
+ <Column Name="UpdateTime" DataType="DateTime" Description="更新时间" Model="False" Category="扩展" />
+ <Column Name="UpdateIP" DataType="String" Description="更新地址" Model="False" Category="扩展" />
+ <Column Name="Remark" DataType="String" Length="500" Description="描述" Category="扩展" />
+ </Columns>
+ <Indexes>
+ <Index Columns="Code" Unique="True" />
+ <Index Columns="ProductId" />
+ <Index Columns="Uuid" />
+ <Index Columns="UpdateTime" />
+ </Indexes>
+ </Table>
+ <Table Name="DeviceHistory" Description="设备历史。记录设备上线下线等操作" >
+ <Columns>
+ <Column Name="Id" DataType="Int64" PrimaryKey="True" Description="编号" />
+ <Column Name="DeviceId" DataType="Int32" Description="设备" />
+ <Column Name="Name" DataType="String" Master="True" Description="名称" />
+ <Column Name="Action" DataType="String" Description="操作" />
+ <Column Name="Success" DataType="Boolean" Description="成功" />
+ <Column Name="TraceId" DataType="String" Description="追踪。用于记录调用链追踪标识,在APM查找调用链" />
+ <Column Name="Creator" DataType="String" Description="创建者。服务端设备" />
+ <Column Name="CreateTime" DataType="DateTime" Description="创建时间" Model="False" />
+ <Column Name="CreateIP" DataType="String" Description="创建地址" Model="False" />
+ <Column Name="Remark" DataType="String" Length="2000" Description="内容" />
+ </Columns>
+ <Indexes>
+ <Index Columns="DeviceId,Id" />
+ <Index Columns="DeviceId,Action,Id" />
+ </Indexes>
+ </Table>
+ <Table Name="DeviceProperty" Description="设备属性。设备的功能模型之一,一般用于描述设备运行时的状态,如环境监测设备所读取的当前环境温度等。一个设备有多个属性,名值表" >
+ <Columns>
+ <Column Name="Id" DataType="Int32" Identity="True" PrimaryKey="True" Description="编号" />
+ <Column Name="DeviceId" DataType="Int32" Description="设备" />
+ <Column Name="Name" DataType="String" Master="True" Description="名称" />
+ <Column Name="NickName" DataType="String" Description="昵称" />
+ <Column Name="Type" DataType="String" Description="类型" />
+ <Column Name="Value" DataType="String" Length="-1" Description="数值。设备上报数值" />
+ <Column Name="Unit" DataType="String" Description="单位" />
+ <Column Name="Enable" DataType="Boolean" Description="启用" />
+ <Column Name="TraceId" DataType="String" Description="追踪。用于记录调用链追踪标识,在APM查找调用链" Model="False" Category="扩展" />
+ <Column Name="CreateTime" DataType="DateTime" Description="创建时间" Model="False" Category="扩展" />
+ <Column Name="CreateIP" DataType="String" Description="创建地址" Model="False" Category="扩展" />
+ <Column Name="UpdateTime" DataType="DateTime" Description="更新时间" Model="False" Category="扩展" />
+ <Column Name="UpdateIP" DataType="String" Description="更新地址" Model="False" Category="扩展" />
+ </Columns>
+ <Indexes>
+ <Index Columns="DeviceId,Name" Unique="True" />
+ <Index Columns="UpdateTime" />
+ </Indexes>
+ </Table>
+ <Table Name="DeviceData" Description="设备数据。设备采集原始数据,按天分表存储" >
+ <Columns>
+ <Column Name="Id" DataType="Int64" PrimaryKey="True" Description="编号" />
+ <Column Name="DeviceId" DataType="Int32" Description="设备" />
+ <Column Name="Name" DataType="String" Master="True" Description="名称。MQTT的Topic,或者属性名" />
+ <Column Name="Kind" DataType="String" Description="类型。数据来源,如PostProperty/PostData/MqttPostData" />
+ <Column Name="Value" DataType="String" Length="2000" Description="数值" />
+ <Column Name="Timestamp" DataType="Int64" Description="时间戳。设备生成数据时的UTC毫秒" />
+ <Column Name="TraceId" DataType="String" Description="追踪标识。用于记录调用链追踪标识,在APM查找调用链" Model="False" Category="扩展" />
+ <Column Name="Creator" DataType="String" Description="创建者。服务端设备" Model="False" Category="扩展" />
+ <Column Name="CreateTime" DataType="DateTime" Description="创建时间" Model="False" Category="扩展" />
+ <Column Name="CreateIP" DataType="String" Description="创建地址" Model="False" Category="扩展" />
+ </Columns>
+ <Indexes>
+ <Index Columns="DeviceId,Id" />
+ <Index Columns="DeviceId,Name,Id" />
+ <Index Columns="DeviceId,Kind,Id" />
+ </Indexes>
+ </Table>
+</Tables>
\ No newline at end of file
diff --git a/IoT.Data/IoT.Data.csproj b/IoT.Data/IoT.Data.csproj
new file mode 100644
index 0000000..47de34a
--- /dev/null
+++ b/IoT.Data/IoT.Data.csproj
@@ -0,0 +1,58 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>netstandard2.1</TargetFramework>
+ <AssemblyTitle>物联网数据层</AssemblyTitle>
+ <Description>IoT数据层</Description>
+ <Company>新生命开发团队</Company>
+ <Copyright>©2002-2022 NewLife</Copyright>
+ <Version>1.2.2022.0214</Version>
+ <FileVersion>1.2.2022.0214</FileVersion>
+ <AssemblyVersion>1.2.*</AssemblyVersion>
+ <Deterministic>false</Deterministic>
+ <LangVersion>latest</LangVersion>
+ <GenerateDocumentationFile>True</GenerateDocumentationFile>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+ <NoWarn>1701;1702;1591</NoWarn>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
+ <NoWarn>1701;1702;1591</NoWarn>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Compile Remove="Entity\Config\**" />
+ <Compile Remove="Entity\Log\**" />
+ <EmbeddedResource Remove="Entity\Config\**" />
+ <EmbeddedResource Remove="Entity\Log\**" />
+ <None Remove="Entity\Config\**" />
+ <None Remove="Entity\Log\**" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Compile Remove="Entity\告警历史.Biz.cs" />
+ <Compile Remove="Entity\告警历史.cs" />
+ <Compile Remove="Entity\告警设置.Biz.cs" />
+ <Compile Remove="Entity\告警设置.cs" />
+ <Compile Remove="Entity\子设备.Biz.cs" />
+ <Compile Remove="Entity\子设备.cs" />
+ <Compile Remove="Entity\设备分组.Biz.cs" />
+ <Compile Remove="Entity\设备分组.cs" />
+ <Compile Remove="Entity\设备命令.Biz.cs" />
+ <Compile Remove="Entity\设备命令.cs" />
+ <Compile Remove="Entity\设备服务状态.Biz.cs" />
+ <Compile Remove="Entity\设备服务状态.cs" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="NewLife.IoT" Version="1.7.2023.205" />
+ <PackageReference Include="NewLife.XCode" Version="11.5.2023.203" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Service Include="{508349b6-6b84-4df5-91f0-309beebad82d}" />
+ </ItemGroup>
+
+</Project>
diff --git a/IoT.Data/xcodetool.exe b/IoT.Data/xcodetool.exe
new file mode 100644
index 0000000..e4b8ea3
Binary files /dev/null and b/IoT.Data/xcodetool.exe differ
diff --git a/IoTEdge/IoTEdge.csproj b/IoTEdge/IoTEdge.csproj
new file mode 100644
index 0000000..4f31944
--- /dev/null
+++ b/IoTEdge/IoTEdge.csproj
@@ -0,0 +1,39 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net7.0</TargetFramework>
+ <AssemblyTitle>物联网网关</AssemblyTitle>
+ <Description>IoT边缘网关</Description>
+ <Company>新生命开发团队</Company>
+ <Copyright>©2002-2023 NewLife</Copyright>
+ <VersionPrefix>1.0</VersionPrefix>
+ <VersionSuffix>$([System.DateTime]::Now.ToString(`yyyy.MMdd`))</VersionSuffix>
+ <Version>$(VersionPrefix).$(VersionSuffix)</Version>
+ <FileVersion>$(Version)</FileVersion>
+ <AssemblyVersion>$(VersionPrefix).*</AssemblyVersion>
+ <Deterministic>false</Deterministic>
+ <OutputPath>..\Bin\IoTEdge</OutputPath>
+ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
+ <GenerateDocumentationFile>True</GenerateDocumentationFile>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <LangVersion>latest</LangVersion>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="NewLife.BACnet" Version="1.0.2023.520-beta0022" />
+ <PackageReference Include="NewLife.Core" Version="10.3.2023.519-beta1319" />
+ <PackageReference Include="NewLife.IoT" Version="1.8.2023.511" />
+ <PackageReference Include="NewLife.Modbus" Version="1.6.2023.511" />
+ <PackageReference Include="NewLife.ModbusRTU" Version="1.6.2023.511" />
+ <PackageReference Include="NewLife.MQTT" Version="1.3.2023.401" />
+ <PackageReference Include="NewLife.NetPing" Version="1.1.2023.511" />
+ <PackageReference Include="NewLife.PC" Version="1.0.2023.511" />
+ <PackageReference Include="NewLife.Schneider" Version="1.0.2023.511" />
+ <PackageReference Include="NewLife.Siemens" Version="1.0.2023.511" />
+ <PackageReference Include="NewLife.Stardust" Version="2.8.2023.520-beta0003" />
+ <PackageReference Include="SmartA2" Version="1.0.2023.519-beta0731" />
+ <PackageReference Include="SmartA4" Version="1.0.2023.519" />
+ </ItemGroup>
+
+</Project>
diff --git a/IoTEdge/Program.cs b/IoTEdge/Program.cs
new file mode 100644
index 0000000..528e127
--- /dev/null
+++ b/IoTEdge/Program.cs
@@ -0,0 +1,45 @@
+using NewLife;
+using NewLife.Log;
+using NewLife.Model;
+using NewLife.MQTT;
+using Stardust;
+
+//!!! 轻量级控制台项目模板
+
+// 启用控制台日志,拦截所有异常
+XTrace.UseConsole();
+
+// 初始化对象容器,提供注入能力
+var services = ObjectContainer.Current;
+services.AddSingleton(XTrace.Log);
+
+// 配置星尘。自动读取配置文件 config/star.config 中的服务器地址
+var star = new StarFactory();
+if (star.Server.IsNullOrEmpty()) star = null;
+
+// 初始化Redis、MQTT、RocketMQ,注册服务到容器
+InitMqtt(services, star?.Tracer);
+
+// 注册后台任务 IHostedService
+var host = services.BuildHost();
+//host.Add<Worker>();
+
+// 异步阻塞,友好退出
+await host.RunAsync();
+
+
+static void InitMqtt(IObjectContainer services, ITracer? tracer)
+{
+ // 引入 MQTT
+ var mqtt = new MqttClient
+ {
+ Tracer = tracer,
+ Log = XTrace.Log,
+
+ Server = "tcp://127.0.0.1:1883",
+ ClientId = Environment.MachineName,
+ UserName = "stone",
+ Password = "Pass@word",
+ };
+ services.AddSingleton(mqtt);
+}
\ No newline at end of file
diff --git a/IoTZero/appsettings.Development.json b/IoTZero/appsettings.Development.json
new file mode 100644
index 0000000..770d3e9
--- /dev/null
+++ b/IoTZero/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "DetailedErrors": true,
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/IoTZero/appsettings.json b/IoTZero/appsettings.json
new file mode 100644
index 0000000..afa02b5
--- /dev/null
+++ b/IoTZero/appsettings.json
@@ -0,0 +1,15 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Urls": "http://*:1880",
+ "ConnectionStrings": {
+ "IoT": "Data Source=..\\Data\\IoT.db;Provider=Sqlite",
+ "IoTData": "Data Source=..\\Data\\IoTData.db;ShowSql=false;Provider=Sqlite",
+ "Membership": "Data Source=..\\Data\\Membership.db;Provider=Sqlite"
+ }
+}
diff --git a/IoTZero/Areas/IoT/Controllers/DeviceController.cs b/IoTZero/Areas/IoT/Controllers/DeviceController.cs
new file mode 100644
index 0000000..a4cee68
--- /dev/null
+++ b/IoTZero/Areas/IoT/Controllers/DeviceController.cs
@@ -0,0 +1,190 @@
+using System.ComponentModel;
+using IoT.Data;
+using IoTEdge.Models;
+using IoTEdge.Services;
+using Microsoft.AspNetCore.Mvc;
+using NewLife;
+using NewLife.Cube;
+using NewLife.IoT.Drivers;
+using NewLife.Log;
+using NewLife.Serialization;
+using NewLife.Web;
+using XCode;
+using XCode.Membership;
+
+namespace IoTEdge.Areas.IoT.Controllers;
+
+[IoTArea]
+[DisplayName("设备管理")]
+[Menu(80, true, Icon = "fa-mobile")]
+public class DeviceController : EntityController<Device>
+{
+ private readonly ITracer _tracer;
+ private readonly PointImportService _pointService;
+
+ static DeviceController()
+ {
+ LogOnChange = true;
+
+ ListFields.RemoveField("Secret", "Uuid", "ProvinceId", "IP", "Period", "Address", "Location", "Logins", "LastLogin", "LastLoginIP", "OnlineTime", "RegisterTime", "Remark", "AreaName");
+ ListFields.RemoveCreateField();
+ ListFields.RemoveUpdateField();
+
+ {
+ var df = ListFields.AddListField("history", "Online");
+ df.DisplayName = "历史";
+ df.Url = "/IoT/DeviceHistory?deviceId={Id}";
+ }
+
+ {
+ var df = ListFields.AddListField("property", "Online");
+ df.DisplayName = "属性";
+ df.Url = "/IoT/DeviceProperty?deviceId={Id}";
+ }
+
+ {
+ var df = ListFields.AddListField("service", "Online");
+ df.DisplayName = "服务";
+ df.Url = "/IoT/DeviceService?deviceId={Id}";
+ }
+
+ {
+ var df = ListFields.AddListField("data", "Online");
+ df.DisplayName = "数据";
+ df.Url = "/IoT/DeviceData?deviceId={Id}";
+ }
+
+ {
+ var df = ListFields.AddListField("event", "Online");
+ df.DisplayName = "事件";
+ df.Url = "/IoT/DeviceEvent?deviceId={Id}";
+ }
+ }
+
+ public DeviceController(ITracer tracer, PointImportService pointService)
+ {
+ _tracer = tracer;
+ _pointService = pointService;
+ }
+
+ protected override IEnumerable<Device> Search(Pager p)
+ {
+ var id = p["Id"].ToInt(-1);
+ if (id > 0)
+ {
+ var node = Device.FindById(id);
+ if (node != null) return new[] { node };
+ }
+
+ var productId = p["productId"].ToInt(-1);
+ var enable = p["enable"]?.ToBoolean();
+
+ var start = p["dtStart"].ToDateTime();
+ var end = p["dtEnd"].ToDateTime();
+
+ //// 如果没有指定产品和主设备,则过滤掉子设备
+ //if (productId < 0 && parentId < 0) parentId = 0;
+
+ return Device.Search(productId, enable, start, end, p["Q"], p);
+ }
+
+ protected override Boolean Valid(Device entity, DataObjectMethodType type, Boolean post)
+ {
+ var fs = type switch
+ {
+ DataObjectMethodType.Insert => AddFormFields,
+ DataObjectMethodType.Update => EditFormFields,
+ _ => null,
+ };
+
+ if (post)
+ {
+ // 使用驱动格式化配置数据
+ var driver = entity.Product?.ProtocolType;
+ if (!driver.IsNullOrEmpty())
+ try
+ {
+ var drv = DriverFactory.Create(driver, null);
+ var pm = drv?.GetDefaultParameter();
+ if (pm != null)
+ if (entity.Parameter.IsNullOrEmpty())
+ entity.Parameter = pm.ToJson(true);
+ else
+ {
+ // 添加缺失配置项
+ var dic = JsonParser.Decode(entity.Parameter);
+ var dic2 = pm.ToDictionary();
+ foreach (var item in dic2)
+ if (!dic.ContainsKey(item.Key)) dic[item.Key] = item.Value;
+ entity.Parameter = dic.ToJson(true);
+ }
+ }
+ catch (Exception ex)
+ {
+ XTrace.WriteException(ex);
+ }
+ }
+
+ return base.Valid(entity, type, post);
+ }
+
+ protected override Int32 OnInsert(Device entity)
+ {
+ var rs = base.OnInsert(entity);
+
+ // 复制产品属性
+ entity.Fix(true);
+
+ entity.Product?.Fix();
+ return rs;
+ }
+
+ protected override Int32 OnUpdate(Device entity)
+ {
+ var rs = base.OnUpdate(entity);
+
+ entity.Fix(false);
+
+ entity.Product?.Fix();
+
+ return rs;
+ }
+
+ protected override Int32 OnDelete(Device entity)
+ {
+ // 删除设备时需要顺便把设备属性删除
+ var dpList = DeviceProperty.FindAllByDeviceId(entity.Id);
+ _ = dpList.Delete();
+
+ var rs = base.OnDelete(entity);
+
+ entity.Product?.Fix();
+
+ return rs;
+ }
+
+ /// <summary>点位导入页面</summary>
+ /// <param name="id"></param>
+ /// <returns></returns>
+ public ActionResult Import(Int32 id)
+ {
+ var dv = Device.FindById(id);
+
+ return View("Import", dv);
+ }
+
+ /// <summary>点位数据上传导入</summary>
+ /// <param name="model"></param>
+ /// <param name="file"></param>
+ /// <returns></returns>
+ [HttpPost]
+ [EntityAuthorize(PermissionFlags.Insert)]
+ public ActionResult Upload(PointImportModel model, IFormFile file)
+ {
+ using var span = _tracer?.NewSpan("DataImport", model);
+
+ _pointService.Import(model, file.OpenReadStream());
+
+ return RedirectToAction("Index", new { id = model.Id });
+ }
+}
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Controllers/DeviceDataController.cs b/IoTZero/Areas/IoT/Controllers/DeviceDataController.cs
new file mode 100644
index 0000000..70cfc8c
--- /dev/null
+++ b/IoTZero/Areas/IoT/Controllers/DeviceDataController.cs
@@ -0,0 +1,225 @@
+using IoT.Data;
+using NewLife;
+using NewLife.Algorithms;
+using NewLife.Cube;
+using NewLife.Cube.Charts;
+using NewLife.Cube.Extensions;
+using NewLife.Cube.ViewModels;
+using NewLife.Data;
+using NewLife.Web;
+using XCode;
+using XCode.Membership;
+
+namespace IoTEdge.Areas.IoT.Controllers;
+
+[IoTArea]
+[Menu(0, false)]
+public class DeviceDataController : EntityController<DeviceData>
+{
+ static DeviceDataController()
+ {
+ ListFields.RemoveField("Id");
+ ListFields.AddListField("Value", null, "Kind");
+
+ {
+ var df = ListFields.GetField("Name") as ListField;
+ //df.DisplayName = "主题";
+ df.Url = "/IoT/DeviceData?deviceId={DeviceId}&name={Name}";
+ }
+ ListFields.TraceUrl("TraceId");
+ }
+
+ protected override IEnumerable<DeviceData> Search(Pager p)
+ {
+ var deviceId = p["deviceId"].ToInt(-1);
+ var name = p["name"];
+
+ var start = p["dtStart"].ToDateTime();
+ var end = p["dtEnd"].ToDateTime();
+
+ if (start.Year < 2000)
+ {
+ start = DateTime.Today;
+ p["dtStart"] = start.ToString("yyyy-MM-dd");
+ p["dtEnd"] = start.ToString("yyyy-MM-dd");
+ }
+
+ if (deviceId > 0 && p.PageSize == 20 && !name.IsNullOrEmpty() && !name.StartsWithIgnoreCase("raw-", "channel-")) p.PageSize = 14400;
+
+ var list = DeviceData.Search(deviceId, name, start, end, p["Q"], p);
+
+ // 单一设备绘制曲线
+ if (list.Count > 0 && deviceId > 0)
+ {
+ var list2 = list.Where(e => !e.Name.StartsWithIgnoreCase("raw-", "channel-") && e.Value.ToDouble(-1) >= 0).OrderBy(e => e.Id).ToList();
+
+ // 绘制曲线图
+ if (list2.Count > 0)
+ {
+ var topics = list2.Select(e => e.Name).Distinct().ToList();
+ var datax = list2.GroupBy(e => e.CreateTime).ToDictionary(e => e.Key, e => e.ToList());
+ //var topics = list2.GroupBy(e => e.Topic).ToDictionary(e => e.Key, e => e.ToList());
+ var chart = new ECharts
+ {
+ Height = 400,
+ };
+ //chart.SetX(list2, _.CreateTime, e => e.CreateTime.ToString("mm:ss"));
+
+ // 构建X轴
+ var minT = datax.Keys.Min();
+ var maxT = datax.Keys.Max();
+ var step = p["sample"].ToInt(-1);
+ if (step > 0)
+ {
+ if (step <= 60)
+ {
+ minT = new DateTime(minT.Year, minT.Month, minT.Day, minT.Hour, minT.Minute, 0, minT.Kind);
+ maxT = new DateTime(maxT.Year, maxT.Month, maxT.Day, maxT.Hour, maxT.Minute, 0, maxT.Kind);
+ }
+ else
+ {
+ minT = new DateTime(minT.Year, minT.Month, minT.Day, minT.Hour, 0, 0, minT.Kind);
+ maxT = new DateTime(maxT.Year, maxT.Month, maxT.Day, maxT.Hour, 0, 0, maxT.Kind);
+ //step = 3600;
+ }
+ var times = new List<DateTime>();
+ for (var dt = minT; dt <= maxT; dt = dt.AddSeconds(step))
+ {
+ times.Add(dt);
+ }
+
+ if (step < 60)
+ {
+ chart.XAxis = new
+ {
+ data = times.Select(e => e.ToString("HH:mm:ss")).ToArray(),
+ };
+ }
+ else
+ {
+ chart.XAxis = new
+ {
+ data = times.Select(e => e.ToString("dd-HH:mm")).ToArray(),
+ };
+ }
+ }
+ else
+ {
+ chart.XAxis = new
+ {
+ data = datax.Keys.Select(e => e.ToString("HH:mm:ss")).ToArray(),
+ };
+ }
+ chart.SetY("数值");
+
+ var max = -9999.0;
+ var min = 9999.0;
+ var dps = DeviceProperty.FindAllByDeviceId(deviceId);
+ var sample = new AverageSampling();
+ //var sample = new LTTBSampling();
+ foreach (var item in topics)
+ {
+ var name2 = item;
+
+ // 使用属性名
+ var dp = dps.FirstOrDefault(e => e.Name == item);
+ if (dp != null && !dp.NickName.IsNullOrEmpty()) name2 = dp.NickName;
+
+ var series = new Series
+ {
+ Name = name2,
+ Type = "line",
+ //Data = tps2.Select(e => Math.Round(e.Value)).ToArray(),
+ Smooth = true,
+ };
+
+ if (step > 0)
+ {
+ //var minD = minT.Date.ToInt();
+ var tps = new List<TimePoint>();
+ foreach (var elm in datax)
+ {
+ // 可能该Topic在这个时刻没有数据,写入空
+ var v = elm.Value.FirstOrDefault(e => e.Name == item);
+ if (v != null)
+ tps.Add(new TimePoint { Time = v.CreateTime.ToInt(), Value = v.Value.ToDouble() });
+ }
+
+ var tps2 = sample.Process(tps.ToArray(), step);
+
+ series.Data = tps2.Select(e => Math.Round(e.Value, 2)).ToArray();
+
+ var m1 = tps2.Select(e => e.Value).Min();
+ if (m1 < min) min = m1;
+ var m2 = tps2.Select(e => e.Value).Max();
+ if (m2 > max) max = m2;
+ }
+ else
+ {
+ var list3 = new List<Object>();
+ foreach (var elm in datax)
+ {
+ // 可能该Topic在这个时刻没有数据,写入空
+ var v = elm.Value.FirstOrDefault(e => e.Name == item);
+ if (v != null)
+ list3.Add(v.Value);
+ else
+ list3.Add('-');
+ }
+ series.Data = list3;
+
+ var m1 = list3.Where(e => e + "" != "-").Select(e => e.ToDouble()).Min();
+ if (m1 < min) min = m1;
+ var m2 = list3.Where(e => e + "" != "-").Select(e => e.ToDouble()).Max();
+ if (m2 > max) max = m2;
+ }
+
+ // 单一曲线,显示最大最小和平均
+ if (topics.Count == 1)
+ {
+ name = name2;
+ series["markPoint"] = new
+ {
+ data = new[] {
+ new{ type="max",name="Max"},
+ new{ type="min",name="Min"},
+ }
+ };
+ series["markLine"] = new
+ {
+ data = new[] {
+ new{ type="average",name="Avg"},
+ }
+ };
+ }
+
+ // 降采样策略 lttb/average/max/min/sum
+ series["sampling"] = "lttb";
+ series["symbol"] = "none";
+
+ // 开启动画
+ series["animation"] = true;
+
+ chart.Add(series);
+ }
+ chart.SetTooltip();
+ chart.YAxis = new
+ {
+ name = "数值",
+ type = "value",
+ min = Math.Ceiling(min) - 1,
+ max = Math.Ceiling(max),
+ };
+ ViewBag.Charts = new[] { chart };
+
+ // 减少数据显示,避免卡页面
+ list = list.Take(100).ToList();
+
+ var ar = Device.FindById(deviceId);
+ if (ar != null) ViewBag.Title = topics.Count == 1 ? $"{name} - {ar}数据" : $"{ar}数据";
+ }
+ }
+
+ return list;
+ }
+}
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Controllers/DeviceHistoryController.cs b/IoTZero/Areas/IoT/Controllers/DeviceHistoryController.cs
new file mode 100644
index 0000000..b49e50a
--- /dev/null
+++ b/IoTZero/Areas/IoT/Controllers/DeviceHistoryController.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using IoT.Data;
+using NewLife.Cube;
+using NewLife.Web;
+using XCode.Membership;
+
+namespace IoTEdge.Areas.IoT.Controllers
+{
+ [IoTArea]
+ [Menu(0, false)]
+ public class DeviceHistoryController : ReadOnlyEntityController<DeviceHistory>
+ {
+ static DeviceHistoryController() => ListFields.RemoveField("ProvinceId");
+
+ protected override IEnumerable<DeviceHistory> Search(Pager p)
+ {
+ var deviceId = p["deviceId"].ToInt(-1);
+ var action = p["action"];
+
+ var start = p["dtStart"].ToDateTime();
+ var end = p["dtEnd"].ToDateTime();
+
+ //if (start.Year < 2000)
+ //{
+ // start = new DateTime(DateTime.Today.Year, 1, 1);
+ // p["dtStart"] = start.ToString("yyyy-MM-dd");
+ //}
+
+ if (start.Year < 2000)
+ {
+ using var split = DeviceHistory.Meta.CreateShard(DateTime.Today);
+ return DeviceHistory.Search(deviceId, action, start, end, p["Q"], p);
+ }
+ else
+ return DeviceHistory.Search(deviceId, action, start, end, p["Q"], p);
+ }
+ }
+}
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Controllers/DevicePropertyController.cs b/IoTZero/Areas/IoT/Controllers/DevicePropertyController.cs
new file mode 100644
index 0000000..5f960d9
--- /dev/null
+++ b/IoTZero/Areas/IoT/Controllers/DevicePropertyController.cs
@@ -0,0 +1,124 @@
+using System.ComponentModel;
+using IoT.Data;
+using IoTEdge.Services;
+using Microsoft.AspNetCore.Mvc;
+using NewLife;
+using NewLife.Cube;
+using NewLife.Cube.Extensions;
+using NewLife.Cube.ViewModels;
+using NewLife.IoT;
+using NewLife.IoT.ThingModels;
+using NewLife.Serialization;
+using NewLife.Web;
+using XCode.Membership;
+
+namespace IoTEdge.Areas.IoT.Controllers;
+
+[IoTArea]
+[Menu(0, false)]
+public class DevicePropertyController : EntityController<DeviceProperty>
+{
+ private readonly ThingService _thingService;
+
+ static DevicePropertyController()
+ {
+ LogOnChange = true;
+
+ ListFields.RemoveField("UnitName", "Length", "Rule", "Readonly", "Locked", "Timestamp", "FunctionId", "Remark");
+ ListFields.RemoveCreateField();
+
+ ListFields.TraceUrl("TraceId");
+
+ {
+ var df = ListFields.GetField("DeviceName") as ListField;
+ df.Url = "/IoT/Device?Id={DeviceId}";
+ }
+ {
+ var df = ListFields.GetField("Name") as ListField;
+ df.Url = "/IoT/DeviceData?deviceId={DeviceId}&name={Name}";
+ }
+ {
+ var df = ListFields.AddDataField("Value", "Unit") as ListField;
+ }
+ {
+ var df = ListFields.AddDataField("Switch", "Address") as ListField;
+ df.DisplayName = "翻转";
+ df.Url = "/IoT/DeviceProperty/Switch?id={Id}";
+ df.DataAction = "action";
+ df.DataVisible = e => (e as DeviceProperty).Type.EqualIgnoreCase("bool");
+ }
+ }
+
+ public DevicePropertyController(ThingService thingService) => _thingService = thingService;
+
+ protected override Boolean Valid(DeviceProperty entity, DataObjectMethodType type, Boolean post)
+ {
+ var fs = type switch
+ {
+ DataObjectMethodType.Insert => AddFormFields,
+ DataObjectMethodType.Update => EditFormFields,
+ _ => null,
+ };
+
+ if (fs != null)
+ {
+ var df = fs.FirstOrDefault(e => e.Name == "Type");
+ if (df != null)
+ {
+ // 基础类型,加上所有产品类型
+ var dic = new Dictionary<String, String>(TypeHelper.GetIoTTypes(true), StringComparer.OrdinalIgnoreCase);
+
+ if (!entity.Type.IsNullOrEmpty() && !dic.ContainsKey(entity.Type)) dic[entity.Type] = entity.Type;
+ df.DataSource = e => dic;
+ }
+ }
+
+ return base.Valid(entity, type, post);
+ }
+
+ protected override IEnumerable<DeviceProperty> Search(Pager p)
+ {
+ var deviceId = p["deviceId"].ToInt(-1);
+
+ var start = p["dtStart"].ToDateTime();
+ var end = p["dtEnd"].ToDateTime();
+
+ return DeviceProperty.Search(deviceId, start, end, p["Q"], p);
+ }
+
+ [EntityAuthorize(PermissionFlags.Insert)]
+ public async Task<ActionResult> Switch(Int32 id)
+ {
+ var msg = "";
+ var entity = DeviceProperty.FindById(id);
+ if (entity != null && entity.Enable)
+ {
+ var value = entity.Value.ToBoolean();
+ value = !value;
+
+ //var rs = await _appService.SetProperty(entity.Device.Code, entity.Name, value);
+ var model = new PropertyModel { Name = entity.Name, Value = value };
+ _thingService.SetProperty(entity.Device, new[] { model }, UserHost);
+
+ // 执行远程调用
+ var dp = entity;
+ if (dp != null)
+ {
+ var input = new
+ {
+ model.Name,
+ model.Value,
+ dp.Address,
+ };
+
+ var rs = await _thingService.InvokeAsync(entity.Device, "SetProperty", input.ToJson(), DateTime.Now.AddSeconds(5));
+ if (rs != null && rs.Status >= ServiceStatus.已完成)
+ {
+ msg = $"{rs.Status} {rs.Data}";
+ }
+ }
+ }
+
+ return JsonRefresh("成功!" + msg, 1000);
+ }
+}
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Controllers/ProductController.cs b/IoTZero/Areas/IoT/Controllers/ProductController.cs
new file mode 100644
index 0000000..00d84a1
--- /dev/null
+++ b/IoTZero/Areas/IoT/Controllers/ProductController.cs
@@ -0,0 +1,99 @@
+using System.ComponentModel;
+using IoT.Data;
+using NewLife.Cube;
+using NewLife.Cube.ViewModels;
+using NewLife.Web;
+
+namespace IoTEdge.Areas.IoT.Controllers
+{
+ [IoTArea]
+ [DisplayName("产品定义")]
+ [Menu(90, true, Icon = "fa-product-hunt")]
+ public class ProductController : EntityController<Product>
+ {
+ static ProductController()
+ {
+ LogOnChange = true;
+
+ ListFields.RemoveField("Secret", "DataFormat", "DynamicRegister", "FixedDeviceCode", "AuthType", "WhiteIP", "Remark");
+ ListFields.RemoveCreateField();
+
+ {
+ var df = ListFields.GetField("DeviceCount") as ListField;
+ df.DisplayName = "{DeviceCount}";
+ df.Url = "/IoT/Device?productId={Id}";
+ //df.DataVisible = (e, f) => (e as Product).DeviceCount > 0;
+ }
+
+ {
+ var df = ListFields.AddListField("function", "UpdateUser");
+ df.DisplayName = "功能定义";
+ df.Url = "/IoT/ProductFunction?productId={Id}";
+ df.Title = "产品的物模型属性";
+ }
+
+ {
+ var df = ListFields.AddListField("tsl", "UPdateUser");
+ df.DisplayName = "功能定义TSL";
+ df.Url = "/IoT/TSL/Edit?productId={Id}";
+ df.Title = "TSL模型";
+ }
+
+ {
+ var df = ListFields.AddListField("publish", "UPdateUser");
+ df.DisplayName = "功能发布";
+ df.Url = "/IoT/ProductFunction/PublishBatch?productId={Id}";
+ df.Title = "批量发布产品功能定义";
+ //df.DataVisible = e => (e as Product).DeviceCount > 0;
+ }
+
+ {
+ var df = ListFields.AddListField("rule", "UpdateUser");
+ df.DisplayName = "规则策略";
+ df.Url = "/IoT/RulePolicy?productId={Id}";
+ }
+
+ {
+ var df = ListFields.AddListField("Log");
+ df.DisplayName = "日志";
+ df.Url = "/Admin/Log?category=产品&linkId={Id}";
+ }
+ }
+
+ protected override IEnumerable<Product> Search(Pager p)
+ {
+ var id = p["Id"].ToInt(-1);
+ if (id > 0)
+ {
+ var entity = Product.FindById(id);
+ if (entity != null) return new[] { entity };
+ }
+
+ var code = p["code"];
+ var protocol = p["protocol"];
+
+ var start = p["dtStart"].ToDateTime();
+ var end = p["dtEnd"].ToDateTime();
+
+ return Product.Search(code, protocol, start, end, p["Q"], p);
+ }
+
+ protected override Boolean Valid(Product entity, DataObjectMethodType type, Boolean post)
+ {
+ var fs = type switch
+ {
+ DataObjectMethodType.Insert => AddFormFields,
+ DataObjectMethodType.Update => EditFormFields,
+ _ => null,
+ };
+
+ if (fs != null)
+ {
+ var df = fs.FirstOrDefault(e => e.Name == "ProtocolType");
+ if (df != null) df.DataSource = e => Driver.GetValids().ToDictionary(e => e.Name, e => e.ToString());
+ }
+
+ return base.Valid(entity, type, post);
+ }
+ }
+}
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/IoTArea.cs b/IoTZero/Areas/IoT/IoTArea.cs
new file mode 100644
index 0000000..dbdaca6
--- /dev/null
+++ b/IoTZero/Areas/IoT/IoTArea.cs
@@ -0,0 +1,13 @@
+using System.ComponentModel;
+using NewLife;
+using NewLife.Cube;
+
+namespace IoTEdge.Areas.IoT;
+
+[DisplayName("网关配置")]
+public class IoTArea : AreaBase
+{
+ public IoTArea() : base(nameof(IoTArea).TrimEnd("Area")) { }
+
+ static IoTArea() => RegisterArea<IoTArea>();
+}
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Views/_ViewImports.cshtml b/IoTZero/Areas/IoT/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..643ea47
--- /dev/null
+++ b/IoTZero/Areas/IoT/Views/_ViewImports.cshtml
@@ -0,0 +1,10 @@
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
+@using NewLife
+@using NewLife.Cube
+@using NewLife.Cube.Extensions
+@using NewLife.Reflection
+@using NewLife.Web
+@using XCode
+@using XCode.Membership
+@using IoT.Data
+@using IoTEdge
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Views/_ViewStart.cshtml b/IoTZero/Areas/IoT/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..de1b51e
--- /dev/null
+++ b/IoTZero/Areas/IoT/Views/_ViewStart.cshtml
@@ -0,0 +1,6 @@
+@{
+ var theme = NewLife.Cube.Setting.Current.Theme;
+ if (String.IsNullOrEmpty(theme)) theme = "ACE";
+
+ Layout = "~/Views/" + theme + "/_Layout.cshtml";
+}
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Views/Device/_Form_Action.cshtml b/IoTZero/Areas/IoT/Views/Device/_Form_Action.cshtml
new file mode 100644
index 0000000..86edde5
--- /dev/null
+++ b/IoTZero/Areas/IoT/Views/Device/_Form_Action.cshtml
@@ -0,0 +1,19 @@
+@model Device
+@using XCode
+@using XCode.Membership;
+@{
+ var entity = Model as IEntity;
+ var isNew = entity.IsNullKey;
+}
+@if (this.Has(PermissionFlags.Insert, PermissionFlags.Update))
+{
+ <div class="clearfix form-actions col-xs-12 col-sm-12 col-md-12">
+ <label class="control-label col-xs-4 col-sm-5 col-md-5"></label>
+ @if (!isNew)
+ {
+ <a href="@Url.Action("Import", "Device", new { Id = Model.Id })" class="btn btn-success btn-sm">导入设备信息</a>
+ }
+ <button type="submit" class="btn btn-success btn-sm"><i class="glyphicon glyphicon-@(isNew ? "plus" : "save")"></i><strong>@(isNew ? "新增" : "保存")</strong></button>
+ <button type="button" class="btn btn-danger btn-sm" onclick="history.go(-1);"><i class="glyphicon glyphicon-remove"></i><strong>取消</strong></button>
+ </div>
+}
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Views/Device/_List_Search.cshtml b/IoTZero/Areas/IoT/Views/Device/_List_Search.cshtml
new file mode 100644
index 0000000..eadaed2
--- /dev/null
+++ b/IoTZero/Areas/IoT/Views/Device/_List_Search.cshtml
@@ -0,0 +1,14 @@
+@using NewLife;
+@using NewLife.Web;
+@using NewLife.Cube;
+@using XCode;
+@using IoT.Data;
+@{
+ var fact = ViewBag.Factory as IEntityFactory;
+ var page = ViewBag.Page as Pager;
+}
+<div class="form-group">
+ <label for="productId" class="control-label">产品:</label>
+ @Html.ForDropDownList("productId", Product.FindAllWithCache(), page["productId"], "全部", true)
+</div>
+@await Html.PartialAsync("_DateRange")
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Views/Device/Import.cshtml b/IoTZero/Areas/IoT/Views/Device/Import.cshtml
new file mode 100644
index 0000000..a847562
--- /dev/null
+++ b/IoTZero/Areas/IoT/Views/Device/Import.cshtml
@@ -0,0 +1,90 @@
+@model Device
+@using NewLife;
+@using NewLife.Web;
+@using NewLife.Cube;
+@using XCode;
+@using IoT.Data;
+@{
+ var fact = ViewBag.Factory as IEntityFactory;
+ var entity = Model as Device;
+}
+<div class="form-horizontal">
+ @using (Html.BeginForm("Upload", null, new { id = Model.Id }, FormMethod.Post, null, new { enctype = "multipart/form-data" }))
+ {
+ <div class="form-group col-xs-12 col-sm-6">
+ <label class="control-label col-xs-3 col-sm-3">名称</label>
+ <div class="input-group col-xs-9 col-sm-9">
+ @entity.Name
+ </div>
+ </div>
+ <div class="form-group col-xs-12 col-sm-6">
+ <label class="control-label col-xs-3 col-sm-3">编码</label>
+ <div class="input-group col-xs-9 col-sm-5">
+ @entity.Code
+ </div>
+ </div>
+ <div class="form-group col-xs-12 col-sm-6">
+ <label class="control-label col-xs-3 col-sm-3">协议</label>
+ <div class="input-group col-xs-9 col-sm-5">
+ <select class="multiselect" id="ProtocolType" name="ProtocolType">
+ <option selected="selected" value="ModbusRTU">Modbus(串口RS232/RS485)</option>
+ <option value="ModbusTCP">Modbus(以太网Tcp)</option>
+ <option value="OpcUA">OPC UA(开放平台通信统一体系结构)</option>
+ <option value="-1"><无></option>
+ </select>
+ </div>
+ <span class="hidden-xs col-sm-4">
+ <span class="middle">设备接入网关的协议类型,如ModbusRTU/ModbusTCP</span>
+ </span>
+
+ </div>
+ <div class="form-group col-xs-12 col-sm-6">
+ <label class="control-label col-xs-3 col-sm-3">设备地址</label>
+ <div class="input-group col-xs-9 col-sm-5">
+ <input class="form-control" id="Address" name="Address" type="text" value="tcp://127.0.0.1:502" />
+ </div>
+ <span class="hidden-xs col-sm-4">
+ <span class="middle">串口号COM1,或/dev/ttyAMA2,或者ModbusTCP的设备地址,tcp://127.0.0.1:502</span>
+ </span>
+ </div>
+ <div class="form-group col-xs-12 col-sm-6">
+ <label class="control-label col-xs-3 col-sm-3">波特率</label>
+ <div class="input-group col-xs-9 col-sm-5">
+ <input class="form-control" id="Baudrate" name="Baudrate" role="number" type="text" value="9600" />
+ </div>
+ <span class="hidden-xs col-sm-4">
+ <span class="middle">仅串口使用,默认9600</span>
+ </span>
+ </div>
+ <div class="form-group col-xs-12 col-sm-6">
+ <label class="control-label col-xs-3 col-sm-3">从站号</label>
+ <div class="input-group col-xs-9 col-sm-5">
+ <input class="form-control" id="Host" name="Host" role="number" type="text" value="1" />
+ </div>
+ <span class="hidden-xs col-sm-4">
+ <span class="middle">默认 1</span>
+ </span>
+ </div>
+ <div class="form-group col-xs-12 col-sm-6">
+ <label class="control-label col-xs-3 col-sm-3">Excel文件</label>
+ <div class="input-group">
+ <span class="input-group-addon">
+ <i class="glyphicon glyphicon-file"></i>
+ </span>
+ <input name="file" type="file" id="file" placeholder="上传文件" />
+ </div>
+ </div>
+ <div class="form-group col-xs-12 col-sm-6">
+ <label class="control-label col-xs-3 col-sm-3">导入模板</label>
+ <div class="input-group col-xs-9 col-sm-5">
+ <a href="~/Content/ModelFile/importModel.xlsx">数据点导入模板.xlsx</a>
+ </div>
+ </div>
+
+ <div class="clearfix form-actions col-xs-12 col-sm-12 col-md-12">
+ <label class="control-label col-xs-4 col-sm-5 col-md-5"></label>
+ <button type="submit" class="btn btn-success btn-sm"><i class="glyphicon glyphicon-plus"></i><strong>上传</strong></button>
+ <button type="button" class="btn btn-danger btn-sm" onclick="history.go(-1);"><i class="glyphicon glyphicon-remove"></i><strong>取消</strong></button>
+ </div>
+ }
+</div>
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Views/DeviceData/_List_Search.cshtml b/IoTZero/Areas/IoT/Views/DeviceData/_List_Search.cshtml
new file mode 100644
index 0000000..730699b
--- /dev/null
+++ b/IoTZero/Areas/IoT/Views/DeviceData/_List_Search.cshtml
@@ -0,0 +1,10 @@
+@using NewLife;
+@using NewLife.Web;
+@using NewLife.Cube;
+@using XCode;
+@using IoT.Data;
+@{
+ var fact = ViewBag.Factory as IEntityFactory;
+ var page = ViewBag.Page as Pager;
+}
+@await Html.PartialAsync("_DateRange")
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Views/DeviceData/_List_Toolbar_Custom.cshtml b/IoTZero/Areas/IoT/Views/DeviceData/_List_Toolbar_Custom.cshtml
new file mode 100644
index 0000000..5115a96
--- /dev/null
+++ b/IoTZero/Areas/IoT/Views/DeviceData/_List_Toolbar_Custom.cshtml
@@ -0,0 +1,16 @@
+@using NewLife;
+@using NewLife.Web;
+@using NewLife.Cube;
+@using XCode;
+@using IoT.Data;
+@{
+ var fact = ViewBag.Factory as IEntityFactory;
+ var page = ViewBag.Page as Pager;
+ var url = page.GetBaseUrl(true, true, false, new[] { "sample" });
+}
+<div class="form-group">
+ <a href="?@url">原始数据</a>
+ <a href="?@url&sample=60">每分钟</a>
+ <a href="?@url&sample=900">每15钟</a>
+ <a href="?@url&sample=3600">每小时</a>
+</div>
diff --git a/IoTZero/Areas/IoT/Views/DeviceHistory/_List_Search.cshtml b/IoTZero/Areas/IoT/Views/DeviceHistory/_List_Search.cshtml
new file mode 100644
index 0000000..730699b
--- /dev/null
+++ b/IoTZero/Areas/IoT/Views/DeviceHistory/_List_Search.cshtml
@@ -0,0 +1,10 @@
+@using NewLife;
+@using NewLife.Web;
+@using NewLife.Cube;
+@using XCode;
+@using IoT.Data;
+@{
+ var fact = ViewBag.Factory as IEntityFactory;
+ var page = ViewBag.Page as Pager;
+}
+@await Html.PartialAsync("_DateRange")
\ No newline at end of file
diff --git a/IoTZero/Areas/IoT/Views/Product/_List_Search.cshtml b/IoTZero/Areas/IoT/Views/Product/_List_Search.cshtml
new file mode 100644
index 0000000..c66c8ff
--- /dev/null
+++ b/IoTZero/Areas/IoT/Views/Product/_List_Search.cshtml
@@ -0,0 +1,14 @@
+@using IoT.Data
+@using NewLife;
+@using NewLife.Web;
+@using NewLife.Cube;
+@using XCode;
+@{
+ var fact = ViewBag.Factory as IEntityFactory;
+ var page = ViewBag.Page as Pager;
+}
+<div class="form-group">
+ <label for="protocol" class="control-label">驱动:</label>
+ @Html.ForDropDownList("protocol", Driver.GetValids().ToDictionary(e=>e.Name,e=>e.ToString()), page["protocol"], "全部", true)
+</div>
+@await Html.PartialAsync("_DateRange")
\ No newline at end of file
diff --git a/IoTZero/IoTZero.csproj b/IoTZero/IoTZero.csproj
new file mode 100644
index 0000000..39b9905
--- /dev/null
+++ b/IoTZero/IoTZero.csproj
@@ -0,0 +1,36 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net7.0</TargetFramework>
+ <AssemblyTitle>物联网服务平台</AssemblyTitle>
+ <Description>IoT服务平台</Description>
+ <Company>新生命开发团队</Company>
+ <Copyright>©2002-2023 NewLife</Copyright>
+ <VersionPrefix>1.0</VersionPrefix>
+ <VersionSuffix>$([System.DateTime]::Now.ToString(`yyyy.MMdd`))</VersionSuffix>
+ <Version>$(VersionPrefix).$(VersionSuffix)</Version>
+ <FileVersion>$(Version)</FileVersion>
+ <AssemblyVersion>$(VersionPrefix).*</AssemblyVersion>
+ <Deterministic>false</Deterministic>
+ <OutputPath>..\Bin\IoTZero</OutputPath>
+ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
+ <GenerateDocumentationFile>True</GenerateDocumentationFile>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <LangVersion>latest</LangVersion>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Folder Include="Areas\IoT\Views\DeviceProperty\" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="NewLife.Cube.Core" Version="5.5.2023.520-beta0001" />
+ <PackageReference Include="NewLife.IoT" Version="1.8.2023.511" />
+ <PackageReference Include="NewLife.Stardust.Extensions" Version="2.8.2023.520-beta0003" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\IoT.Data\IoT.Data.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/IoTZero/Program.cs b/IoTZero/Program.cs
new file mode 100644
index 0000000..a037882
--- /dev/null
+++ b/IoTZero/Program.cs
@@ -0,0 +1,89 @@
+using IoTEdge.Services;
+using NewLife.Caching;
+using NewLife.Cube;
+using NewLife.Log;
+using XCode;
+
+// 日志输出到控制台,并拦截全局异常
+XTrace.UseConsole();
+
+var builder = WebApplication.CreateBuilder(args);
+var services = builder.Services;
+
+// 配置星尘。借助StarAgent,或者读取配置文件 config/star.config 中的服务器地址、应用标识、密钥
+var star = services.AddStardust(null);
+
+// 把数据目录指向上层,例如部署到 /root/iot/edge/,这些目录放在 /root/iot/
+var set = NewLife.Setting.Current;
+if (set.IsNew)
+{
+ set.LogPath = "../Log";
+ set.DataPath = "../Data";
+ set.BackupPath = "../Backup";
+ set.Save();
+}
+var set2 = CubeSetting.Current;
+if (set2.IsNew)
+{
+ set2.AvatarPath = "../Avatars";
+ set2.UploadPath = "../Uploads";
+ set2.Save();
+}
+var set3 = XCodeSetting.Current;
+if (set3.IsNew)
+{
+ set3.ShowSQL = false;
+ set3.EntityCacheExpire = 60;
+ set3.SingleCacheExpire = 60;
+ set3.Save();
+}
+
+// 注册服务
+services.AddSingleton<DataConsumerService>();
+
+services.AddSingleton<ThingService>();
+services.AddSingleton<DataService>();
+services.AddSingleton<QueueService>();
+services.AddSingleton<PushDataQueueService>();
+services.AddSingleton<RuleService>();
+services.AddSingleton<PointImportService>();
+
+services.AddHttpClient("hc", e => e.Timeout = TimeSpan.FromSeconds(5));
+
+services.AddSingleton<ICache, MemoryCache>();
+
+// 后台服务
+services.AddHostedService<EdgeHostedService>();
+
+services.AddControllersWithViews();
+
+// 引入魔方
+services.AddCube();
+
+var app = builder.Build();
+
+// 预热数据层,执行反向工程建表等操作
+EntityFactory.InitConnection("Membership");
+EntityFactory.InitConnection("Log");
+EntityFactory.InitConnection("Cube");
+EntityFactory.InitConnection("IoT");
+
+// 使用Cube前添加自己的管道
+if (app.Environment.IsDevelopment())
+ app.UseDeveloperExceptionPage();
+else
+ app.UseExceptionHandler("/CubeHome/Error");
+
+// 使用魔方
+app.UseCube(app.Environment);
+
+app.UseAuthorization();
+
+app.UseEndpoints(endpoints =>
+{
+ endpoints.MapControllerRoute(
+ name: "default",
+ pattern: "{controller=CubeHome}/{action=Index}/{id?}");
+});
+
+app.Run();
diff --git a/IoTZero/Properties/launchSettings.json b/IoTZero/Properties/launchSettings.json
new file mode 100644
index 0000000..f60d546
--- /dev/null
+++ b/IoTZero/Properties/launchSettings.json
@@ -0,0 +1,28 @@
+{
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:58926",
+ "sslPort": 0
+ }
+ },
+ "profiles": {
+ "IoTZero": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5269",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/IoTZero/Services/DataService.cs b/IoTZero/Services/DataService.cs
new file mode 100644
index 0000000..48c22db
--- /dev/null
+++ b/IoTZero/Services/DataService.cs
@@ -0,0 +1,130 @@
+using IoT.Data;
+using IoTEdge.Models;
+using NewLife;
+using NewLife.IoT.ThingModels;
+using NewLife.Log;
+
+namespace IoTEdge.Services;
+
+/// <summary>数据服务</summary>
+public class DataService
+{
+ private readonly PushDataQueueService _pushQueue;
+ private readonly ITracer _tracer;
+
+ /// <summary>实例化数据服务</summary>
+ /// <param name="pushQueue"></param>
+ /// <param name="tracer"></param>
+ public DataService(PushDataQueueService pushQueue, ITracer tracer)
+ {
+ _pushQueue = pushQueue;
+ _tracer = tracer;
+ }
+
+ #region 方法
+ /// <summary>
+ /// 插入设备原始数据,异步批量操作
+ /// </summary>
+ /// <param name="deviceId"></param>
+ /// <param name="time"></param>
+ /// <param name="name"></param>
+ /// <param name="value"></param>
+ /// <param name="kind"></param>
+ /// <param name="ip"></param>
+ /// <returns></returns>
+ public Int32 AddData(Int32 deviceId, Int64 time, String name, String value, String kind, String ip)
+ {
+ if (value.IsNullOrEmpty()) return 0;
+
+ // 原始数据不再落库,仅用于解析
+ if (name.StartsWithIgnoreCase("raw-", "channel-")) return 0;
+
+ using var span = _tracer?.NewSpan("AddData", $"{deviceId}-{name}-{value}");
+
+ //// 客户端时间
+ //if (time.Year < 2000) time = DateTime.Now;
+ //// 在按天分表加上并行插入的模式下,有一定几率出现主键冲突,因此提前生成雪花Id
+ //var snow = DeviceData.Meta.Factory.Snow;
+
+ var traceId = DefaultSpan.Current?.TraceId;
+
+ var entity = new DeviceData()
+ {
+ //Id = snow.NewId(time),
+ DeviceId = deviceId,
+ Name = name,
+ Value = value,
+ Kind = kind,
+
+ Timestamp = time,
+ TraceId = traceId,
+ Creator = Environment.MachineName,
+ CreateTime = DateTime.Now,
+ CreateIP = ip,
+ };
+
+ var rs = entity.SaveAsync() ? 1 : 0;
+
+ var dv = Device.FindById(deviceId);
+ var msg = new DataModelMessage()
+ {
+ Name = name,
+ Time = time,
+ Value = value,
+ DeviceCode = dv.Code,
+ ProductCode = dv.Product?.Code
+ };
+
+ var container = new MessageSendContainer<DataModelMessage>(msg);
+
+ // 推送主队列
+ _pushQueue.GetDataQueue()?.Add(container);
+
+ return rs;
+ }
+
+ /// <summary>添加事件</summary>
+ /// <param name="deviceId"></param>
+ /// <param name="model"></param>
+ /// <param name="ip"></param>
+ /// <returns></returns>
+ public void AddEvent(Int32 deviceId, EventModel model, String ip)
+ {
+ var traceId = DefaultSpan.Current?.TraceId;
+ //var snow = DeviceEvent.Meta.Factory.Snow;
+
+ var ev = new DeviceEvent
+ {
+ //Id = snow.NewId(time),
+ DeviceId = deviceId,
+
+ Type = model.Type,
+ Name = model.Name,
+ Remark = model.Remark,
+
+ Timestamp = model.Time,
+ TraceId = traceId,
+ Creator = Environment.MachineName,
+ CreateTime = DateTime.Now,
+ CreateIP = ip,
+ };
+
+ ev.SaveAsync();
+
+ var dv = Device.FindById(deviceId);
+ var msg = new EventModelMessage()
+ {
+ DeviceCode = dv.Code,
+ ProductCode = dv.Product?.Code,
+ Name = model.Name,
+ Time = model.Time,
+ Type = model.Type,
+ Remark = model.Remark,
+ };
+
+ var container = new MessageSendContainer<EventModelMessage>(msg);
+
+ _pushQueue.GetEventQueue()?.Add(container);
+ }
+ #endregion
+}
\ No newline at end of file
diff --git a/IoTZero/Services/QueueService.cs b/IoTZero/Services/QueueService.cs
new file mode 100644
index 0000000..7e83c96
--- /dev/null
+++ b/IoTZero/Services/QueueService.cs
@@ -0,0 +1,102 @@
+using NewLife;
+using NewLife.Caching;
+using NewLife.IoT.ThingModels;
+using NewLife.Log;
+using NewLife.Serialization;
+
+namespace IoTEdge.Services;
+
+/// <summary>队列服务</summary>
+public class QueueService
+{
+ #region 属性
+ /// <summary>
+ /// 队列主机
+ /// </summary>
+ public ICache Host { get; set; }
+
+ private readonly ITracer _tracer;
+ #endregion
+
+ #region 构造
+ /// <summary>
+ /// 实例化队列服务
+ /// </summary>
+ public QueueService(ICache cache, ITracer tracer)
+ {
+ Host = cache;
+ _tracer = tracer;
+ }
+ #endregion
+
+ /// <summary>
+ /// 获取指定设备的命令队列
+ /// </summary>
+ /// <returns></returns>
+ public IProducerConsumer<String> GetQueue() => Host.GetQueue<String>("deviceService");
+
+ /// <summary>
+ /// 向指定设备发送命令
+ /// </summary>
+ /// <param name="model"></param>
+ /// <returns></returns>
+ public Int32 Publish(ServiceModel model)
+ {
+ using var span = _tracer?.NewSpan(nameof(Publish), model);
+
+ var q = GetQueue();
+ return q.Add(model.ToJson());
+ }
+
+ IProducerConsumer<String> _consumer;
+ /// <summary>
+ /// 消费服务队列
+ /// </summary>
+ /// <returns></returns>
+ public async Task<ServiceModel> ConsumeAsync()
+ {
+ _consumer ??= GetQueue();
+
+ var msg = await _consumer.TakeOneAsync(15);
+ if (msg.IsNullOrEmpty()) return null;
+
+ return msg.ToJsonEntity<ServiceModel>();
+ }
+
+ /// <summary>
+ /// 获取指定设备的服务响应队列
+ /// </summary>
+ /// <param name="serviceLogId"></param>
+ /// <returns></returns>
+ public IProducerConsumer<String> GetReplyQueue(Int64 serviceLogId) => Host.GetQueue<String>($"service:{serviceLogId}");
+
+ /// <summary>
+ /// 发送消息到服务响应队列
+ /// </summary>
+ /// <param name="model"></param>
+ public void Publish(ServiceReplyModel model)
+ {
+ var topic = $"service:{model.Id}";
+ var queue = Host.GetQueue<String>(topic);
+
+ // 发送消息,并设置过期时间
+ queue.Add(model.ToJson());
+
+ Host.SetExpire(topic, TimeSpan.FromMinutes(10));
+ }
+
+ /// <summary>
+ /// 消费响应消息
+ /// </summary>
+ /// <param name="serviceLogId"></param>
+ /// <returns></returns>
+ public async Task<ServiceReplyModel> ConsumeOneAsync(Int64 serviceLogId)
+ {
+ var consumer = GetReplyQueue(serviceLogId);
+
+ var msg = await consumer.TakeOneAsync(15);
+ if (msg.IsNullOrEmpty()) return null;
+
+ return msg.ToJsonEntity<ServiceReplyModel>();
+ }
+}
\ No newline at end of file
diff --git a/IoTZero/Services/ThingService.cs b/IoTZero/Services/ThingService.cs
new file mode 100644
index 0000000..51c49e5
--- /dev/null
+++ b/IoTZero/Services/ThingService.cs
@@ -0,0 +1,258 @@
+using IoT.Data;
+using IoT.Data.Models;
+using NewLife;
+using NewLife.Caching;
+using NewLife.IoT;
+using NewLife.IoT.ThingModels;
+using NewLife.IoT.ThingSpecification;
+using NewLife.Log;
+using NewLife.Reflection;
+using XCode;
+
+namespace IoTEdge.Services;
+
+/// <summary>物模型服务</summary>
+public class ThingService
+{
+ private readonly DataService _dataService;
+ private readonly QueueService _queue;
+ private readonly RuleService _ruleService;
+ private readonly QueueService _queueService;
+ private readonly ITracer _tracer;
+ private static readonly ICache _cache = new MemoryCache();
+
+ /// <summary>
+ /// 实例化物模型服务
+ /// </summary>
+ /// <param name="dataService"></param>
+ /// <param name="queue"></param>
+ /// <param name="ruleService"></param>
+ /// <param name="segmentService"></param>
+ /// <param name="tracer"></param>
+ public ThingService(DataService dataService, QueueService queue, RuleService ruleService, QueueService queueService, ITracer tracer)
+ {
+ _dataService = dataService;
+ _queue = queue;
+ _ruleService = ruleService;
+ _queueService = queueService;
+ _tracer = tracer;
+ }
+
+ #region 属性
+ private void VerifyModel(Device device, String name, FunctionKinds kind)
+ {
+ // 强校验产品,需要判断该属性是否在功能定义里面
+ var prd = device.Product;
+ if (prd != null && prd.VerifyModel)
+ {
+ if (!prd.Functions.Any(e => e.Enable && e.Identifier == name && e.Kind == kind))
+ throw new Exception($"设备[{device}]的物模型不支持{kind}[{name}]");
+ }
+ }
+
+ /// <summary>上报数据,写入属性表、数据表、分段数据表</summary>
+ /// <param name="device"></param>
+ /// <param name="name"></param>
+ /// <param name="value"></param>
+ /// <param name="timestamp"></param>
+ /// <param name="kind"></param>
+ /// <param name="ip"></param>
+ /// <returns></returns>
+ public IDeviceProperty PostData(Device device, String name, Object value, Int64 timestamp, String kind, String ip)
+ {
+ var dp = PostProperty(device, name, value, timestamp, ip);
+
+ // 记录数据流水,使用经过处理的属性数值字段
+ if (dp != null)
+ {
+ _dataService.AddData(dp.DeviceId, timestamp, dp.Name, dp.Value, kind, ip);
+ }
+
+ return dp;
+ }
+
+ /// <summary>设备属性上报</summary>
+ /// <param name="device">设备</param>
+ /// <param name="items">名值对</param>
+ /// <param name="ip">IP地址</param>
+ /// <returns></returns>
+ public Int32 PostProperty(Device device, PropertyModel[] items, String ip)
+ {
+ if (items == null) return -1;
+
+ var rs = 0;
+ foreach (var item in items)
+ {
+ PostData(device, item.Name, item.Value, 0, "PostProperty", ip);
+
+ rs++;
+ }
+
+ // 触发指定设备的联动策略
+ if (rs > 0) _ruleService.Execute(device.Id);
+
+ return rs;
+ }
+
+ /// <summary>设备属性上报</summary>
+ /// <param name="device">设备</param>
+ /// <param name="name">属性名</param>
+ /// <param name="value">数值</param>
+ /// <param name="timestamp">时间戳</param>
+ /// <param name="ip">IP地址</param>
+ /// <returns></returns>
+ public IDeviceProperty PostProperty(Device device, String name, Object value, Int64 timestamp, String ip)
+ {
+ using var span = _tracer?.NewSpan("PostProperty", $"{device.Id}-{name}-{value}");
+
+ var entity = GetProperty(device, name);
+ if (entity == null)
+ {
+ // 产品开启强校验物模型属性,不会自动创建属性
+ if (device.Product.VerifyModel) return null;
+
+ VerifyModel(device, name, FunctionKinds.Property);
+
+ var key = $"{device.Id}###{name}";
+ entity = DeviceProperty.GetOrAdd(key,
+ k => DeviceProperty.FindByNameAndDeviceId(name, device.Id),
+ k => new DeviceProperty
+ {
+ DeviceId = device.Id,
+ Name = name,
+ NickName = name,
+ Enable = true,
+
+ CreateTime = DateTime.Now,
+ CreateIP = ip
+ });
+ }
+
+ // 检查是否锁定
+ if (!entity.Enable) return null;
+
+ //if (timestamp == 0) timestamp = DateTime.UtcNow.ToLong();
+
+ // 检查数据是否越界
+ var function = ProductFunction.FindById(entity.FunctionId);
+ if (function != null && !Valid(function, device.Id, entity, value)) return null;
+
+ // 修正数字精度,小数点位数
+ value = FixData(value, function);
+
+ entity.Name = name;
+ //entity.Enable = true;
+ entity.SetValue(value);
+
+ var hasDirty = (entity as IEntity).Dirtys[nameof(entity.Value)];
+
+ var now = DateTime.Now;
+ entity.LastPost = now;
+ entity.Timestamp = timestamp;
+ entity.TraceId = DefaultSpan.Current?.TraceId;
+ entity.UpdateTime = now;
+ entity.UpdateIP = ip;
+
+ // 如果短时间内数据没有变化(无脏数据),则不需要保存属性
+ var period = 60;
+ if (hasDirty || period <= 0 || entity.LastPost.AddSeconds(period) < now)
+ {
+ // 属性上报直接更新,数据上报异步更新
+ if (entity.Id == 0)
+ entity.Save();
+ else
+ entity.SaveAsync();
+ }
+
+ return entity;
+ }
+
+ /// <summary>修正数据精度</summary>
+ /// <param name="value"></param>
+ /// <param name="function"></param>
+ /// <returns></returns>
+ public static Object FixData(Object value, ProductFunction function)
+ {
+ // 修正数字精度,小数点位数
+ if (function == null || function.Step <= 0 || !function.DataType.EqualIgnoreCase("float", "single", "double"))
+ return value;
+
+ // 计算小数点后位数
+ var step = function.Step.ToString();
+ var p = step.IndexOf('.');
+ if (p > 0)
+ {
+ // 0.001,p=1,len=3
+ var len = step.Length - 1 - p;
+ if (len > 0)
+ {
+ var d = value.ToDouble();
+ d = Math.Round(d, p);
+ value = d;
+ }
+ }
+
+ return value;
+ }
+
+ /// <summary>获取设备属性对象,长时间缓存,便于加速属性保存</summary>
+ /// <param name="device"></param>
+ /// <param name="name"></param>
+ /// <returns></returns>
+ private DeviceProperty GetProperty(Device device, String name)
+ {
+ var key = $"{device.Id}###{name}";
+ if (_cache.TryGetValue<DeviceProperty>(key, out var property)) return property;
+
+ using var span = _tracer?.NewSpan("GetProperty", $"{device.Id}-{name}");
+
+ var entity = device.Properties.FirstOrDefault(e => e.Name.EqualIgnoreCase(name));
+ if (entity != null)
+ _cache.Set(key, entity, 3600);
+
+ return entity;
+ }
+
+ /// <summary>检查数据是否越界</summary>
+ /// <param name="function"></param>
+ /// <param name="deviceId"></param>
+ /// <param name="dp"></param>
+ /// <param name="val"></param>
+ /// <returns></returns>
+ private Boolean Valid(ProductFunction function, Int32 deviceId, DeviceProperty dp, Object val)
+ {
+ // 判断是否溢出
+ if (function.Max - function.Min > 0)
+ {
+ var d = val.ToDouble();
+ if (d < function.Min || d > function.Max) return false;
+ }
+
+ // 最大间隔。超过该值时抛弃
+ if (function.MaxStep > 0)
+ {
+ var key = $"thing:maxstep:{deviceId}:{function.Identifier}";
+ var has = _cache.TryGetValue<Double>(key, out var lastValue);
+ if (!has)
+ {
+ if (dp != null)
+ {
+ lastValue = dp.Value.ToDouble();
+ has = true;
+ }
+ }
+
+ // 记录最后一次数据,即使没有采用。如果连续来了两个超限值,第二个将可能被采用
+ _cache.Set(key, val.ToDouble(), 3600);
+
+ if (has)
+ {
+ var d2 = val.ToDouble() - lastValue;
+ if (Math.Abs(d2) > function.MaxStep) return false;
+ }
+ }
+
+ return true;
+ }
+ #endregion
+}
\ No newline at end of file
diff --git a/Readme.MD b/Readme.MD
new file mode 100644
index 0000000..ef79253
--- /dev/null
+++ b/Readme.MD
@@ -0,0 +1,15 @@
+# 轻量级IoT解决方案 ZeroIoT
+轻量级IoT解决方案,IoT网关通过HTTP/MQTT接入IoT平台,提供数据采集和远程控制功能。IoT平台仅包含必要接口,架构简单清晰,面向小型物联网项目。
+
+演示地址:http://iot.feifan.link
+
+## 客户端网关
+IoTEdge,是轻量级物联网网关,运行在远端设备附近的工控机(A2/A4)上。
+南向对接各种电子电气设备,或者传感器,支持NewLife.IoT生态的所有物联网驱动协议。
+北向通过HTTP/MQTT接入IoT平台,提供数据采集和远程控制功能。
+
+## 服务端平台
+IoTZero,是轻量级物联网平台,运行在云端服务器上。
+平台提供设备鉴权、数据上报和指令下发等接口。
+平台暂存设备数据,提供数据查询和数据分析功能。
+平台提供向外推送数据的能力。
\ No newline at end of file
diff --git a/ZeroIoT.sln b/ZeroIoT.sln
new file mode 100644
index 0000000..aca6d39
--- /dev/null
+++ b/ZeroIoT.sln
@@ -0,0 +1,43 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31717.71
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IoT.Data", "IoT.Data\IoT.Data.csproj", "{060707B1-D0E3-4CEB-AA2E-94C986A18B71}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C3436398-F186-4BF2-908F-C6C70BB749D3}"
+ ProjectSection(SolutionItems) = preProject
+ .editorconfig = .editorconfig
+ Readme.MD = Readme.MD
+ EndProjectSection
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IoTZero", "IoTZero\IoTZero.csproj", "{108DA7C0-AE8C-4259-8AA5-07409303AE22}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IoTEdge", "IoTEdge\IoTEdge.csproj", "{9A45E66E-4F0B-40F7-B43E-5F3A18C5D0F0}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {060707B1-D0E3-4CEB-AA2E-94C986A18B71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {060707B1-D0E3-4CEB-AA2E-94C986A18B71}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {060707B1-D0E3-4CEB-AA2E-94C986A18B71}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {060707B1-D0E3-4CEB-AA2E-94C986A18B71}.Release|Any CPU.Build.0 = Release|Any CPU
+ {108DA7C0-AE8C-4259-8AA5-07409303AE22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {108DA7C0-AE8C-4259-8AA5-07409303AE22}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {108DA7C0-AE8C-4259-8AA5-07409303AE22}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {108DA7C0-AE8C-4259-8AA5-07409303AE22}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9A45E66E-4F0B-40F7-B43E-5F3A18C5D0F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9A45E66E-4F0B-40F7-B43E-5F3A18C5D0F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9A45E66E-4F0B-40F7-B43E-5F3A18C5D0F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9A45E66E-4F0B-40F7-B43E-5F3A18C5D0F0}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {37112253-83DF-4E65-AA72-1D0DCBE8CDE7}
+ EndGlobalSection
+EndGlobal