nanoFramework.Azure.Devices.Client.FullyManaged 1.2.50
前缀已保留
dotnet add package nanoFramework.Azure.Devices.Client.FullyManaged --version 1.2.50
NuGet\Install-Package nanoFramework.Azure.Devices.Client.FullyManaged -Version 1.2.50
<PackageReference Include="nanoFramework.Azure.Devices.Client.FullyManaged" Version="1.2.50" />
paket add nanoFramework.Azure.Devices.Client.FullyManaged --version 1.2.50
#r "nuget: nanoFramework.Azure.Devices.Client.FullyManaged, 1.2.50"
// Install nanoFramework.Azure.Devices.Client.FullyManaged as a Cake Addin #addin nuget:?package=nanoFramework.Azure.Devices.Client.FullyManaged&version=1.2.50 // Install nanoFramework.Azure.Devices.Client.FullyManaged as a Cake Tool #tool nuget:?package=nanoFramework.Azure.Devices.Client.FullyManaged&version=1.2.50
欢迎来到 .NET nanoFramework Azure.Devices.Client 库代码库
构建状态
组件 | 构建状态 | NuGet 软件包 |
---|---|---|
nanoFramework.Azure.Devices.Client | ||
nanoFramework.Azure.Devices.Client.FullyManaged |
查看实际应用
您可以通过观看微软 IoT Show 中的这个视频来了解 Azure SDK 和一个使用 .NET nanoFramework 的真实示例
nanoFramework.Azure.Devices.Client 与 nanoFramework.Azure.Devices.Client.FullyManaged
nanoFramework.Azure.Devices.Client.FullyManaged
的 nuget 已构建为与运行的本地硬件 独立。因此,它不会使用 X509Certificate,而是使用字节数组。它不会使用 nanoFramework.M2Mqtt
库,而是使用一个名为 nanoFramework.M2Mqtt.Core
的抽象,并通过接口使用。
这允许的主要场景是让您自己的 MQTT 代理并在没有 System.Net 的设备上运行。这允许通过实现 MQTT 客户端的调制解调器进行连接。您可以几乎重用用于本地网络启用设备以及使用调制解调器设备的同一代码。
用法
重要:您必须连接到一个具有有效IP地址和有效日期的网络。请检查网络助手提供的示例,以确保您两者都满足。
此Azure IoT Hub SDK使用MQTT。因此,您需要确保可以使用TLS协议连接到端口号8883。如果您连接的是企业网络,这可能会被阻止。在大多数情况下,这不是一个问题。
命名空间、类名称和方法尽可能地接近.NET C# Azure IoT SDK。这使得代码更容易在完整的.Net框架和nanoFramework环境中移植。
证书
您有两种方法来提供正确的Azure IoT TLS证书
- 将其解析到构造函数中
- 将其存储到设备中
AzureCertificates方便地在其中包含用于连接到Azure IoT的根证书。当前的根证书是Baltimore Root CA,可以一直用到2022年6月。从2022年6月开始的 Digicert Global Root 2 是要使用的证书。更多信息,请参阅以下博客。
通过构造函数
截至2023年10月15日,您必须将新的Azure DigiCert Global Root G2证书嵌入到您的代码中,以正确与IoT端服务通信
const string AzureRootCA = @"-----BEGIN CERTIFICATE-----
MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI
2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx
1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ
q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz
tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ
vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP
BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV
5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY
1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4
NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG
Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91
8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe
pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl
MrY=
-----END CERTIFICATE-----
";
DeviceClient azureIoT = new DeviceClient(IotBrokerAddress, DeviceID, SasKey, azureCert: new X509Certificate(AzureRootCA));
注意:当使用完全托管的库时,您必须传递一个
byte[]
而不是一个X509Certificate
。您所使用的代理可能支持PEM或DER证书。请确保您使用正确的一个,并查看供应商文档。PEM证书是DEM证书的base64编码版本,通常以.crt
扩展名出现。
您还可以将二进制证书放置在程序资源中,只需从其中获取证书
X509Certificate azureRootCACert = new X509Certificate(Resources.GetBytes(Resources.BinaryResources.AzureCAcertificate));
DeviceClient azureIoT = new DeviceClient(IotBrokerAddress, DeviceID, SasKey, azureCert: azureRootCACert);
注意:当证书到期时,您必须完全刷新设备以使用新证书。
存储证书到设备中
您可以将证书存储在设备闪存中而不是在代码中,这样如果必须更改证书,您只需要清理当前存储并上传新的一个。编辑网络属性
转到常规
选项卡
浏览选择您的证书,它可以是二进制(crt,der)或字符串形式(pem,txt),并选择“确定”。在连接期间,将自动选择连接所需的证书。
创建DeviceClient
您可以使用对称密钥或证书连接到Azure IoT Hub。以下示例展示了如何使用对称密钥
const string DeviceID = "nanoEdgeTwin";
const string IotBrokerAddress = "youriothub.azure-devices.net";
const string SasKey = "yoursaskey";
DeviceClient azureIoT = new DeviceClient(IotBrokerAddress, DeviceID, SasKey);
注意:请参阅前面的部分以了解如何更好地解析您的证书。示例显示的是已上传到设备中的证书,而不是代码中的。
Azure IoT Plug &; Play
Azure IoT Plug &; Play也受支持。在创建DeviceClient时,您需要提供一个模型ID
DeviceClient azureIoT = new DeviceClient(IotBrokerAddress, DeviceID, SasKey, modelID:"dtmi:com:example:Thermostat;1");
注意:模型ID必须在创建DeviceClient时传递,以后无法传递。
报告属性
支持报告Plug &; Play属性。这是一个全面示例,展示了如何检查您是否接收到了感兴趣的属性
const string TargetTemerature = "targetTemperature";
DeviceClient azureIoT = new DeviceClient(Secrets.IotHub, Secrets.DeviceName, Secrets.SasKey, azureCert: new X509Certificate(Resource.GetBytes(Resource.BinaryResources.AzureRoot)), modelId: "dtmi:com:example:Thermostat;1");
azureIoT.TwinUpdated += AzureTwinUpdated;
azureIoT.Open();
void AzureTwinUpdated(object sender, TwinUpdateEventArgs e)
{
if (e.Twin.Contains(TargetTemerature))
{
// We got an update for the target temperature
var target = e.Twin[TargetTemerature];
Debug.WriteLine($"Target temperature updated: {target}");
PropertyAcknowledge targetReport = new() { Version = (int)e.Twin.Version, Status = PropertyStatus.Completed, Description = "All perfect", Value = target };
TwinCollection twin = new TwinCollection();
twin.Add(TargetTemerature, targetReport.BuildAcknowledge());
azureIoT.UpdateReportedProperties(twin);
}
}
在此示例中,我们感兴趣接收的属性称为targetTemperature
。要接收其更新,我们在twin更新上进行了订阅。一旦我们检查到该属性存在,我们就可以通过E.Twin[Target Temperature]
获取其值。
发布可写属性的模式非常简单。它涉及构建一个PropertyAcknowledge
,创建一个TwinCollection,然后添加属性名称,在这里是我们的targetTemperature
。当然,您可以添加更多属性以报告。请注意,添加到TwinCollection中的不是直接的对象,而是BuildAcknowledge()
。完成后,只需通过UpdateReportedProperties
方法请求库更新双胞胎。
接收命令
IoT Plug & Play命令是一种方法回调。参见文档的后续部分,了解如何使用它们。在我们的例子中,方法被调用为getMaxMinReport
。C#中方法的名称必须
与DTDL文件中的名称完全相同。
DeviceClient azureIoT = new DeviceClient(Secrets.IotHub, Secrets.DeviceName, Secrets.SasKey, azureCert: new X509Certificate(Resource.GetBytes(Resource.BinaryResources.AzureRoot)), modelId: "dtmi:com:example:Thermostat;1");
azureIoT.AddMethodCallback(getMaxMinReport);
azureIoT.Open();
string getMaxMinReport(int rid, string payload)
{
TemperatureReporting reporting = new() { avgTemp = 20, maxTemp = 42, minTemp = 12.34, startTime = DateTime.UtcNow.AddDays(-10), endTime = DateTime.UtcNow };
return JsonConvert.SerializeObject(reporting);
}
在这个例子中,预期的结果是对象。只需填充对象并将其序列化为命令所期望的JSON,并返回它。如果该命令有任何参数,它将位于有效负载中。
获取和更新双胞胎
您只需调用GetTwin
函数即可请求您的Azure IoT双胞胎。
var twin = azureIoT.GetTwin(new CancellationTokenSource(20000).Token);
if (twin == null)
{
Debug.WriteLine($"Can't get the twins");
azureIoT.Close();
return;
}
Debug.WriteLine($"Twin DeviceID: {twin.DeviceId}, #desired: {twin.Properties.Desired.Count}, #reported: {twin.Properties.Reported.Count}");
注意:使用一个将在一定时间后取消的CancellationToken
非常重要。否则,这将阻塞线程,直到收到双胞胎为止。
双胞胎具有属性,报告的和期望的。它们是集合,您可以获取或尝试获取任何元素。
您可以通过这种方式简单地报告您的双胞胎:
TwinCollection reported = new TwinCollection();
reported.Add("firmware", "myNano");
reported.Add("sdk", 0.2);
azureIoT.UpdateReportedProperties(reported);
您还有等待双胞胎更新确认的选项,在这种情况下,使用一个可以取消的CancellationToken
。否则,检查将被忽略。
注意:如果未检查双胞胎接收确认或未按时到达,则该函数将返回false。
您还可以订阅任何双胞胎更新
azureIoT.TwinUpdated += TwinUpdatedEvent;
void TwinUpdatedEvent(object sender, TwinUpdateEventArgs e)
{
Debug.WriteLine($"Twin update received: {e.Twin.Count}");
}
注意:一些调制解调器在消息长度方面有限制。该消息包含双胞胎。请确保在使用完全管理的库时检查这些限制。
发送消息
您必须使用SendMessage
函数发送任何类型的消息或遥测到Azure IoT。至于其他函数,您有使用可以取消的CancellationToken
以确保传递的可能性。如果使用不能取消的,则传递保证将被忽略,并且函数将返回false。
var isReceived = azureIoT.SendMessage($"{{\"Temperature\":42,\"Pressure\":1024}}", new CancellationTokenSource(5000).Token);
Debug.WriteLine($"Message received by IoT Hub: {isReceived}");
注意:消息将以您创建设备时创建的默认服务质量发送。您不会对质量0
得到任何答复。在这种情况下,您可以将其简化为
azureIoT.SendMessage($"{{\"Temperature\":42,\"Pressure\":1024}}");
云到设备消息
您可以为接收云到设备消息注册事件
azureIoT.CloudToDeviceMessage += CloudToDeviceMessageEvent;
// The following example shows how to display all keys in debug
void CloudToDeviceMessageEvent(object sender, CloudToDeviceMessageEventArgs e)
{
Debug.WriteLine($"Message arrived: {e.Message}");
foreach (string key in e.Properties.Keys)
{
Debug.Write($" Key: {key} = ");
if (e.Properties[key] == null)
{
Debug.WriteLine("null");
}
else
{
Debug.WriteLine((string)e.Properties[key]);
}
}
// e.Message contains the message itself
if(e.Message == "stop")
{
ShoudIStop = true;
}
}
注意:sender
是一个DeviceClient
类,然后您可以发送消息、确认或任何您放在那里的逻辑。
注意:一些调制解调器在消息长度和主题长度方面有限制。主题长度包含属性包。请确保在使用完全管理的库时检查这些限制。
方法回调
同样支持方法回调。您可以注册和注销您的命名方法。以下是一些示例
azureIoT.AddMethodCallback(MethodCallbackTest);
azureIoT.AddMethodCallback(MakeAddition);
azureIoT.AddMethodCallback(RaiseExceptionCallbackTest);
string MethodCallbackTest(int rid, string payload)
{
Debug.WriteLine($"Call back called :-) rid={rid}, payload={payload}");
return "{\"Yes\":\"baby\",\"itisworking\":42}";
}
string MakeAddition(int rid, string payload)
{
Hashtable variables = (Hashtable)JsonConvert.DeserializeObject(payload, typeof(Hashtable));
int arg1 = (int)variables["arg1"];
int arg2 = (int)variables["arg2"];
return $"{{\"result\":{arg1 + arg2}}}";
}
string RaiseExceptionCallbackTest(int rid, string payload)
{
// This will properly return as well the exception error
throw new Exception("I got you, it's to test the 504");
}
重要:方法名称是区分大小写的。所以请确保在C#中使用相同的名称对函数进行命名。
注意:一些调制解调器在消息长度方面有限制。该消息包含有效负载。请确保在使用完全管理的库时检查这些限制。
状态更新事件
有一个状态更新事件可用
azureIoT.StatusUpdated += StatusUpdatedEvent;
void StatusUpdatedEvent(object sender, StatusUpdatedEventArgs e)
{
Debug.WriteLine($"Status changed: {e.IoTHubStatus.Status}, {e.IoTHubStatus.Message}");
// You may want to reconnect or use a similar retry mechanism
////if (e.IoTHubStatus.Status == Status.Disconnected)
////{
//// mqtt.Open();
////}
}
注意:某些调制解调器在MQTT实现方面有限制,您可能不会收到所有更新。请确保在使用完全管理的库时检查这些限制。
请注意,这些是基于状态变化的,因此一旦接收到连接或断开连接事件,一旦发生其他事件(如接收孪生体),它们就会被其他事件立即替换。
QoS 级别
默认情况下,设备 SDK 连接到 IoT Hub 时使用 QoS 1 与 IoT hub 交换消息。您可以通过设置 DeviceClient
构造函数中的 qosLevel
参数来更改此设置。
以下是您可以使用现有的 QoS 级别:
- AtMostOnce:代理/客户端将交付消息一次,不需要确认。
- AtLeastOnce:代理/客户端至少交付一次消息,需要确认。
- ExactlyOnce:代理/客户端将通过四步握手来精确地交付消息一次。
虽然可以配置 QoS 0(AtMostOnce)以实现更快的消息交换,但您应该注意,交付并不保证也不会得到确认。因此,QoS 0 通常被称为“发射并忘记”。
模块支持
支持模块,您必须使用构造函数传递模块 ID,无论是通过 SAS 令牌还是通过证书。一切其他功能运行正常,就像普通设备一样。完全支持包括模块直接方法、遥测以及当然还有孪生体!
例如这里使用 SAS 令牌。请注意,证书也完全受支持。如果您没有在设备上存储 Azure 根证书,您需要在构造函数中传入它。
const string DeviceID = "nanoEdgeTwin";
const string ModuleID = "myModule";
const string IotBrokerAddress = "youriothub.azure-devices.net";
const string SasKey = "yoursaskey";
DeviceClient module = new DeviceClient(IotBrokerAddress, DeviceID, ModuleID, SasKey);
Azure IoT 设备配置服务 (DPS) 支持
此 SDK 还支持 Azure IoT 设备配置服务。支持使用对称密钥或证书的组配置和单个配置场景。要了解 DPS 的背后的机制,建议阅读文档。
使用对称密钥进行配置
对于对称密钥配置,您只需要以下元素:
- 注册 ID
- ID 范围
- 设备名称
- 用于组配置的密钥或派生密钥
代码很简单
const string RegistrationID = "nanoDPStTest";
const string DpsAddress = "global.azure-devices-provisioning.net";
const string IdScope = "0ne01234567";
const string SasKey = "alongkeyencodedbase64";
// See the previous sections in the SDK help, you either need to have the Azure certificate embedded
// Either passing it in the constructor
X509Certificate azureCA = new X509Certificate(DpsSampleApp.Resources.GetBytes(DpsSampleApp.Resources.BinaryResources.BaltimoreRootCA_crt));
var provisioning = ProvisioningDeviceClient.Create(DpsAddress, IdScope, RegistrationID, SasKey, azureCA);
var myDevice = provisioning.Register(new CancellationTokenSource(60000).Token);
if(myDevice.Status != ProvisioningRegistrationStatusType.Assigned)
{
Debug.WriteLine($"Registration is not assigned: {myDevice.Status}, error message: {myDevice.ErrorMessage}");
return;
}
// You can then create the device
var device = new DeviceClient(myDevice.AssignedHub, myDevice.DeviceId, SasKey, nanoFramework.M2Mqtt.Messages.MqttQoSLevel.AtLeastOnce, azureCA);
// Open it and continue like for the previous sections
var res = device.Open();
if(!res)
{
Debug.WriteLine($"can't open the device");
return;
}
如果要使用DPS 模型,则必须将模型的 ID 传递给 ProvisioningDeviceClient
和 DeviceClient
构造函数。上面的代码需要以下更改。
将模型 ID 添加为常量
public const string ModelId = "dtmi:orgpal:palthree:palthree_demo_0;1";
创建包含模型 ID 的附加有效负载信息,并将其与 DPS 注册一起发送,然后将其传递到 Register
调用中。
var pnpPayload = new ProvisioningRegistrationAdditionalData
{
JsonData = PnpConvention.CreateDpsPayload(ModelId),
};
var myDevice = provisioning.Register(pnpPayload, new CancellationTokenSource(60000).Token);
创建设备客户端,将模型 ID 传递给构造函数中的相应参数。
var device = new DeviceClient(myDevice.AssignedHub, myDevice.DeviceId, SasKey, nanoFramework.M2Mqtt.Messages.MqttQoSLevel.AtLeastOnce, azureCA, ModelId);
注意:如对 DeviceClient
一样,您需要确保正确连接到网络,设备上也有正确的数据和时间。
使用证书进行配置
对于对称密钥配置,您只需要以下元素:
- 注册 ID
- ID 范围
- 设备名称
- 设备证书
- 确保您的 IoT Hub 也知道您使用的根/中间证书,否则您的设备配置后,将无法连接到您的 IoT Hub。
代码很简单
const string RegistrationID = "nanoCertTest";
const string DpsAddress = "global.azure-devices-provisioning.net";
const string IdScope = "0ne0034F11A";
const string cert = @"
-----BEGIN CERTIFICATE-----
Your certificate
-----END CERTIFICATE-----
";
const string privateKey = @"
-----BEGIN ENCRYPTED PRIVATE KEY-----
the encrypted private key
-----END ENCRYPTED PRIVATE KEY-----
";
// See the previous sections in the SDK help, you either need to have the Azure certificate embedded
// Either passing it in the constructor
X509Certificate azureCA = new X509Certificate(DpsSampleApp.Resources.GetBytes(DpsSampleApp.Resources.BinaryResources.BaltimoreRootCA_crt));
// Note: if your private key is not protected with a password, you don't need to pass it
// You can as well store your certificate directly in the device certificate store
// And you can store it as a resource as well if needed
X509Certificate2 deviceCert = new X509Certificate2(cert, privateKey, "1234");
var provisioning = ProvisioningDeviceClient.Create(DpsAddress, IdScope, RegistrationID, deviceCert, azureCA);
var myDevice = provisioning.Register(new CancellationTokenSource(60000).Token);
if(myDevice.Status != ProvisioningRegistrationStatusType.Assigned)
{
Debug.WriteLine($"Registration is not assigned: {myDevice.Status}, error message: {myDevice.ErrorMessage}");
return;
}
// You can then create the device
var device = new DeviceClient(myDevice.AssignedHub, myDevice.DeviceId, deviceCert, nanoFramework.M2Mqtt.Messages.MqttQoSLevel.AtLeastOnce, azureCA);
// Open it and continue like for the previous sections
var res = device.Open();
if(!res)
{
Debug.WriteLine($"can't open the device");
return;
}
附加有效负载
也支持附加有效负载。您可以在调用 Register
函数时,在 ProvisioningRegistrationAdditionalData
类中将它设置为 JSON 字符串。当设备已配置后,也可能会提供附加有效负载。
注意:一些调制解调器在消息长度方面有限制。该消息包含有效负载。请确保在使用完全管理的库时检查这些限制。
反馈和文档
有关文档、提供反馈、问题以及了解如何贡献,请参阅主存储库。
加入我们的 Discord 社区 这里。
鸣谢
此项目的贡献者名单可在此处找到:CONTRIBUTORS。
许可证
nanoFramework 类库采用MIT 许可证授权。
行为准则
本项目采用了贡献者契约定义的行为准则,以明确我们社区中的预期行为。更多信息请参阅.NET Foundation 行为准则。
.NET Foundation
本项目由.NET Foundation支持。
产品 | 版本 兼容和额外的计算目标框架版本。 |
---|---|
.NET Framework | net 是兼容的。 |
-
- nanoFramework.CoreLibrary (>= 1.15.5)
- nanoFramework.Json (>= 2.2.122)
- nanoFramework.M2Mqtt.Core (>= 5.1.138)
- nanoFramework.Runtime.Native (>= 1.6.12)
- nanoFramework.System.Threading (>= 1.1.32)
NuGet 包
此包未使用任何 NuGet 包。
GitHub 仓库 (1)
显示对 nanoFramework.Azure.Devices.Client.FullyManaged 依赖的前 1 个流行 GitHub 仓库
仓库 | 星标 |
---|---|
nanoframework/Samples
🍬 nanoFramework 团队用于测试、概念验证和其他探索性工作的代码示例
|
版本 | 下载 | 最后更新 |
---|---|---|
1.2.50 | 35 | 7/30/2024 |
1.2.47 | 168 | 5/20/2024 |
1.2.45 | 86 | 5/13/2024 |
1.2.43 | 70 | 5/13/2024 |
1.2.41 | 119 | 4/30/2024 |
1.2.38 | 128 | 4/9/2024 |
1.2.36 | 95 | 4/9/2024 |
1.2.34 | 108 | 4/3/2024 |
1.2.32 | 96 | 4/3/2024 |
1.2.29 | 163 | 1/29/2024 |
1.2.25 | 87 | 1/26/2024 |
1.2.23 | 94 | 1/24/2024 |
1.2.21 | 83 | 1/22/2024 |
1.2.16 | 242 | 11/23/2023 |
1.2.14 | 114 | 11/10/2023 |
1.2.12 | 98 | 11/8/2023 |
1.2.10 | 133 | 10/23/2023 |
1.2.7 | 114 | 10/10/2023 |
1.2.5 | 104 | 10/10/2023 |
1.2.3 | 169 | 9/5/2023 |
1.2.1 | 109 | 9/4/2023 |