工业物联网(IIOT)是物联网的主要分支,在工业场景里使用广泛。今天我们来说说基于ESP32核心模块的多协议网关采集器如何加上ADC,DIO, DAC实现数据采集控制,打通OPC-UA通道,实现工业物联网的相关功能。
各种传感器模拟量采集,以及限位器电磁阀等开关量的状态读取和机械执行机构的控制是工业数据采集控制系统不可或缺的基本功能,本采集器所使用的ESP32主芯片本身自带有IO口和内置ADC,但精度在要求比较高一点的工业控制领域感觉有点差强人意,其有限的IO也限制了用8080并口来与专业的ADC芯片接口,但可以用SPI口进行ADC芯片的接口。
我们选取了安富莱电子的16位高精度ADC数据采集模块,这个颜值还是非常不错,还专门配备了屏蔽罩,ERIC大神就是工匠精神的典范。
当然硬石的光藕隔离的继电器也不错
信号隔离板
有了这几个硬件板卡做起来,通用的ADC,DIO基本上在多协议控制器上都可以实现了,而且完全可以满足工业现场复杂的电磁环境需要。
(1)CONVST(A/B)由高拉低后,BUSY会被拉高,当BUSY从高到低,完成一次转换。开启AD采集的信号 : CONVST(A/B)由高拉低(低电平至少保持45ns)
开始转换信号 : BUSY被拉高
转换完成信号 : BUSY由高被拉低
(2)当转换完成(判断BUSY从高到低),然后把CS拉低可以开始读取数据(注意读取完成后,CS拉回高)
①CS 拉低,开始读取
②一个数据16位,先高8位,后低8位
③读取一个完整的字节,需要连续读取2次(每次读8位)。
④总共8个通道,从第一通道到第8通道,连续顺序的传输,所以:
通道n的高8位就是第2*(n-1)+1次读取,低8位是2*(n-1)+2次读取(注:前提是spi传输函数i每次读8位)
CONVA/B 控制AD采集
开始采集信号 : CONVA 低电平,控制方法:
配置PWM模式
CONVA所接引脚配置为PWM模式,PWM产生波形,可以控制好AD的采样频率—采样频率即为PWM波形的频率。
//code by GreatCong
//https://github.com/GreatCong/ESP_proj
//利用ESP32控制呼吸灯的PWM模块实现对AD CONVA的控制,实现采样率的控制
void Init_pwm(void){
ledc_timer_config_t ledc_timer = {
.bit_num = LEDC_TIMER_10_BIT,//range is 0--1024-1
.freq_hz = 10000,//10kHZ
.speed_mode = LEDC_HIGH_SPEED_MODE,
.timer_num = LEDC_TIMER_0
};
ledc_timer_config(&ledc_timer);
ledc_channel_config_t ledc_channel = {
.channel = LEDC_CHANNEL_0,
// .duty = 512,//50%
.duty = 0,//50%
.gpio_num = AD_CONVEST,
// .intr_type = LEDC_INTR_FADE_END,
.intr_type = LEDC_INTR_DISABLE,//disable ledx interrupt
.speed_mode = LEDC_HIGH_SPEED_MODE,
.timer_sel = LEDC_TIMER_0
};
ledc_channel_config(&ledc_channel);
// ledc_fade_func_install(0);//OR intr_alloc_flags with ESP_INTR_FLAG_IRAM because the fade isr is in IRAM
}
/**
* [Init_spi description]
* note: master SPI
*/
void Init_spi(void){
esp_err_t ret;
//Configuration for the SPI bus
spi_bus_config_t buscfg={
.mosi_io_num=AD_MOSI,
.miso_io_num=AD_MISO,
.sclk_io_num=AD_SCLK,
.quadwp_io_num=-1,
.quadhd_io_num=-1
};
//Configuration for the SPI device on the other side of the bus
spi_device_interface_config_t devcfg={
.clock_speed_hz=20*1000*1000, //Clock out at 20 MHz
.mode=2, //SPI mode 0
.spics_io_num=AD_CS, //CS pin
//.cs_ena_posttrans=3, //Keep the CS low 3 cycles after transaction, to stop slave from missing the last bit when CS has less propagation delay than CLK
.queue_size=3*4
//.pre_cb=lcd_spi_pre_transfer_callback, //Specify pre-transfer callback to handle D/C line
};
//Initialize the SPI bus and add the device we want to send stuff to.
ret=spi_bus_initialize(HSPI_HOST, &buscfg, 1);//DMA1
assert(ret==ESP_OK);
ret=spi_bus_add_device(HSPI_HOST, &devcfg, &AD_SPI_handle);
assert(ret==ESP_OK);
}
/*
This ISR is called when the handshake line goes high.
*/
static void IRAM_ATTR gpio_handshake_isr_handler(void* arg)
{
//Give the semaphore.
// BaseType_t mustYield=false;
// xSemaphoreGiveFromISR(rdySem, &mustYield);
// if (mustYield) {
// //spi_device_transmit(AD_SPI_handle, &spi_t);
spi_t.tx_buffer = rx_data;
spi_t.length = 8*8;
//uint8_t rx_data = 0;
spi_t.rx_buffer = rx_data;
if(spi_device_queue_trans(AD_SPI_handle, &spi_t, 5)!=ESP_OK){//keep 5 tick
inter_state = true;
}
AD_custom_event_t ad_data;
for (int i = 0; i < 8; ++i)
{
/* code */
ad_data.data[i] = rx_data[i];
}
xQueueSendFromISR(AD_evt_queue, &ad_data, NULL);
// portYIELD_FROM_ISR();
// }
//printf("inter ok\n");//not used in interrupt for "printf",will abort
//inter_state = true;
}
static void spi_adc_task(void * arg){
AD_custom_event_t ad_data_rev;
while(true) {
if(xQueueReceive(AD_evt_queue, (void *)&ad_data_rev, portMAX_DELAY)) {
if(ad_data_rev.data[0]!=0){
for (int i = 0; i < 4; ++i)
{
/* code */
printf("%d ", ad_data_rev.data[2*i]*255+ad_data_rev.data[2*i+1]);
// putchar(1);
}
printf("\n");
// printf("%d,%d,%d\n",rx_data[0],rx_data[1],rx_data[2]);
}//if
}//if queue
if(inter_state){
printf("inter OK\n");
inter_state = false;
}
}//while
}
将采集的模拟电压值数据转换成物理值常用的是采用分段线性标定法,通过定标将电压值变成物理意义的测量数值,通常采用上位机可视化界面实现,如下图所示
//标定算法
float Caculate(ADName Idx , float InVoltage)
{
float Value=0,X1=0,Y1=0,X2=0,Y2=0;
float KK=0;
float Bb=0;
float TestValue=0;
int i=0;
float ZeroValue;
// if(bCacuateZeroValue==TRUE)
ZeroValue=ADRes[Idx].CoEfficient.ZeroValue;
// else
// ZeroValue=0;
TestValue=InVoltage;
//1 Mode
//First < Last 升幂
if(ADRes[Idx].CoEfficient.SampleValue[0]<ADRes[Idx].CoEfficient.SampleValue[ADRes[Idx].CoEfficient.Num-1])
{
for( i=0;i< ADRes[Idx].CoEfficient.Num ;i++ )
{
if( (TestValue>ADRes[Idx].CoEfficient.SampleValue[i])
&& ( TestValue<=ADRes[Idx].CoEfficient.SampleValue[i+1] ))
{
X1= ADRes[Idx].CoEfficient.SampleValue[i];
X2= ADRes[Idx].CoEfficient.SampleValue[i+1];
Y1= ADRes[Idx].CoEfficient.InputValue[i];
Y2= ADRes[Idx].CoEfficient.InputValue[i+1];
if((X2-X1)==0)
break;
KK=(Y2-Y1)/(X2-X1);
Bb=(X2*Y1-X1*Y2)/(X2-X1);
Value=KK*TestValue+Bb-ZeroValue;
break;
}
//low of the Min Caliber Value
if( (TestValue<=ADRes[Idx].CoEfficient.SampleValue[0]) )
{
X1= ADRes[Idx].CoEfficient.SampleValue[0];
X2= ADRes[Idx].CoEfficient.SampleValue[1];
Y1= ADRes[Idx].CoEfficient.InputValue[0];
Y2= ADRes[Idx].CoEfficient.InputValue[1];
if((X2-X1)==0)
break;
KK=(Y2-Y1)/(X2-X1);
Bb=(X2*Y1-X1*Y2)/(X2-X1);
Value=KK*TestValue+Bb-ZeroValue;
break;
}
//high of the Max Caliber Value
if( (TestValue>=ADRes[Idx].CoEfficient.SampleValue[ADRes[Idx].CoEfficient.Num-1]) )
{
X1= ADRes[Idx].CoEfficient.SampleValue[ADRes[Idx].CoEfficient.Num-2];
X2= ADRes[Idx].CoEfficient.SampleValue[ADRes[Idx].CoEfficient.Num-1];
Y1= ADRes[Idx].CoEfficient.InputValue[ADRes[Idx].CoEfficient.Num-2];
Y2= ADRes[Idx].CoEfficient.InputValue[ADRes[Idx].CoEfficient.Num-1];
if((X2-X1)==0)
break;
KK=(Y2-Y1)/(X2-X1);
Bb=(X2*Y1-X1*Y2)/(X2-X1);
Value=KK*TestValue+Bb-ZeroValue;
break;
}
}
}
//2 Mode
//First > Last 降幂
if(ADRes[Idx].CoEfficient.SampleValue[0]>ADRes[Idx].CoEfficient.SampleValue[ADRes[Idx].CoEfficient.Num-1])
{
for( i=0;i<ADRes[Idx].CoEfficient.Num;i++)
{
if( (TestValue<ADRes[Idx].CoEfficient.SampleValue[i])
&& ( TestValue>=ADRes[Idx].CoEfficient.SampleValue[i+1] ))
{
X1= ADRes[Idx].CoEfficient.SampleValue[i+1];
X2= ADRes[Idx].CoEfficient.SampleValue[i];
Y1= ADRes[Idx].CoEfficient.InputValue[i+1];
Y2= ADRes[Idx].CoEfficient.InputValue[i];
if((X2-X1)==0)
break;
KK=(Y2-Y1)/(X2-X1);
Bb=(X2*Y1-X1*Y2)/(X2-X1);
Value=KK*TestValue+Bb-ZeroValue;
break;
}
if( (TestValue>=ADRes[Idx].CoEfficient.SampleValue[0]) )
{
X1= ADRes[Idx].CoEfficient.SampleValue[1];
X2= ADRes[Idx].CoEfficient.SampleValue[0];
Y1= ADRes[Idx].CoEfficient.InputValue[1];
Y2= ADRes[Idx].CoEfficient.InputValue[0];
if((X2-X1)==0)
break;
KK=(Y2-Y1)/(X2-X1);
Bb=(X2*Y1-X1*Y2)/(X2-X1);
Value=KK*TestValue+Bb-ZeroValue;
break;
}
if( TestValue<=ADRes[Idx].CoEfficient.SampleValue[ADRes[Idx].CoEfficient.Num-1] )
{
X1= ADRes[Idx].CoEfficient.SampleValue[ADRes[Idx].CoEfficient.Num-1];
X2= ADRes[Idx].CoEfficient.SampleValue[ADRes[Idx].CoEfficient.Num-2];
Y1= ADRes[Idx].CoEfficient.InputValue[ADRes[Idx].CoEfficient.Num-1];
Y2= ADRes[Idx].CoEfficient.InputValue[ADRes[Idx].CoEfficient.Num-2];
if((X2-X1)==0)
break;
KK=(Y2-Y1)/(X2-X1);
Bb=(X2*Y1-X1*Y2)/(X2-X1);
Value=KK*TestValue+Bb-ZeroValue;
break;
}
}
}
return Value;
}
最后需要打通OPC-UA协议,以此为代表的IIOT代表工业4.0的核心,其架构如下,结合了物联网及工业控制相关协议和云架构,实现工业网络和互联网的联通。OPC-UA面向对象的概念的基因为其拓展应用场景提供了强大生命力。
OPC UA适用于现场设备,控制系统,制造执行系统和企业资源规划系统等应用领域的制造软件。 这些系统旨在交换信息,并为工业过程使用命令和控制。 OPC UA定义了一个通用的基础架构模型,以便于此信息交换OPC UA指定以下内容:
•表示结构、行为和语义的信息模型。
•应用程序之间交互的消息模型。
•在端点之间传输数据的通信模型。
•保证系统间互操作性的一致性模型。
OPC UA是一种独立于平台的标准,各种系统和设备可以通过各种类型的网络在客户端和服务器之间发送消息进行通信。 它支持强大的安全通信,保证客户端和服务器的身份,并抵御攻击。 OPC UA定义服务器可能提供的服务集,并且各个服务器指定客户端支持哪些服务集。 信息使用OPC UA定义和供应商定义的数据类型传达,服务器定义客户端可以动态发现的对象模型。服务器可以提供对当前和历史数据以及警报和事件的访问,以通知客户端重要更改。 OPC UA可以映射到各种通信协议,并且可以以各种方式对数据进行编码以折衷可移植性和效率。
OPC UA提供一致的,集成的AddressSpace和服务模型。 这允许单个OPC UA服务器将数据,警报和事件以及历史记录集成到其AddressSpace中,并使用一整套服务来提供对它们的访问。 这些服务(Services)还包括一个集成的安全模型。
OPC UA还允许服务器为客户端提供从AddressSpace访问的对象的类型定义。 这样可以使用信息模型来描述AddressSpace的内容。 OPC UA允许以许多不同的格式显示数据,包括二进制结构和XML文档。 数据的格式可以由OPC,其他标准组织或供应商定义。 通过AddressSpace,客户端可以向服务器查询描述数据格式的元数据。 在许多情况下,没有预编程的数据格式知识的客户端将能够在运行时确定格式并正确使用数据。
OPC UA增加了对节点之间的许多关系的支持,而不仅限于单个层次结构。 以这种方式,OPC UA服务器可以按照一组客户端通常想要查看数据的方式定制各种层次结构中的数据。 这种灵活性,结合对类型定义的支持,使OPC UA适用于各种各样的问题领域。 如下图所示,OPC UA不仅仅针对SCADA,PLC和DCS接口,而且也是提供更高级别功能之间更大的互操作性的一种方式。
OPC UA旨在提供已发布数据的鲁棒性。 所有OPC服务器的主要功能是发布数据和事件通知。 OPC UA为客户端提供了快速检测和恢复与这些传输相关联的通信故障的机制,而无需等待基础协议提供的长时间超时。
OPC UA旨在支持从车间PLC到企业服务器的各种服务器。这些服务器的特点是具有广泛的大小,性能,执行平台和功能功能。 因此,OPC UA定义了一套全面的功能,而服务器可以实现这些功能的一部分。 为了促进互操作性,OPC UA定义了称为“配置文件”的子集,服务器可能声称其一致性。 然后,客户端可以发现服务器的配置文件,并根据配置文件定制与该服务器的交互。
采用Open62541协议栈实现OPC-UA服务器功能
static UA_StatusCode
UA_ServerConfig_setUriName(UA_ServerConfig *uaServerConfig, const char *uri, const char *name)
{
// delete pre-initialized values
UA_String_deleteMembers(&uaServerConfig->applicationDescription.applicationUri);
UA_LocalizedText_deleteMembers(&uaServerConfig->applicationDescription.applicationName);
uaServerConfig->applicationDescription.applicationUri = UA_String_fromChars(uri);
uaServerConfig->applicationDescription.applicationName.locale = UA_STRING_NULL;
uaServerConfig->applicationDescription.applicationName.text = UA_String_fromChars(name);
for (size_t i = 0; i < uaServerConfig->endpointsSize; i++)
{
UA_String_deleteMembers(&uaServerConfig->endpoints[i].server.applicationUri);
UA_LocalizedText_deleteMembers(
&uaServerConfig->endpoints[i].server.applicationName);
UA_String_copy(&uaServerConfig->applicationDescription.applicationUri,
&uaServerConfig->endpoints[i].server.applicationUri);
UA_LocalizedText_copy(&uaServerConfig->applicationDescription.applicationName,
&uaServerConfig->endpoints[i].server.applicationName);
}
return UA_STATUSCODE_GOOD;
}
static void opcua_task(void *arg)
{
UA_Int32 sendBufferSize = 32768;
UA_Int32 recvBufferSize = 32768;
ESP_ERROR_CHECK(esp_task_wdt_add(NULL));
ESP_LOGI(TAG, "Fire up OPC UA Server.");
UA_Server *server = UA_Server_new();
UA_ServerConfig *config = UA_Server_getConfig(server);
UA_ServerConfig_setMinimalCustomBuffer(config, 4840, 0, sendBufferSize, recvBufferSize);
const char *appUri = "open62541.esp32.server";
UA_String hostName = UA_STRING("opcua-esp32");
#ifdef ENABLE_MDNS
config->discovery.mdnsEnable = true;
config->discovery.mdns.mdnsServerName = UA_String_fromChars(appUri);
config->discovery.mdns.serverCapabilitiesSize = 2;
UA_String *caps = (UA_String *)UA_Array_new(2, &UA_TYPES[UA_TYPES_STRING]);
caps[0] = UA_String_fromChars("LDS");
caps[1] = UA_String_fromChars("NA");
config->discovery.mdns.serverCapabilities = caps;
// We need to set the default IP address for mDNS since internally it's not able to detect it.
tcpip_adapter_ip_info_t default_ip;
esp_err_t ret = tcpip_adapter_get_ip_info(TCPIP_ADAPTER_IF_STA, &default_ip);
if ((ESP_OK == ret) && (default_ip.ip.addr != INADDR_ANY))
{
config->discovery.ipAddressListSize = 1;
config->discovery.ipAddressList = (uint32_t *)UA_malloc(sizeof(uint32_t) * config->discovery.ipAddressListSize);
memcpy(config->discovery.ipAddressList, &default_ip.ip.addr, sizeof(uint32_t));
}
else
{
ESP_LOGI(TAG, "Could not get default IP Address!");
}
#endif
UA_ServerConfig_setUriName(config, appUri, "OPC_UA_Server_ESP32");
UA_ServerConfig_setCustomHostname(config, hostName);
/* Add Information Model Objects Here */
addLEDMethod(server);
addCurrentTemperatureDataSourceVariable(server);
addRelay0ControlNode(server);
addRelay1ControlNode(server);
ESP_LOGI(TAG, "Heap Left : %d", xPortGetFreeHeapSize());
UA_StatusCode retval = UA_Server_run_startup(server);
if (retval == UA_STATUSCODE_GOOD)
{
while (running)
{
UA_Server_run_iterate(server, false);
ESP_ERROR_CHECK(esp_task_wdt_reset());
taskYIELD();
}
UA_Server_run_shutdown(server);
}
ESP_ERROR_CHECK(esp_task_wdt_delete(NULL));
}
void time_sync_notification_cb(struct timeval *tv)
{
ESP_LOGI(SNTP_TAG, "Notification of a time synchronization event");
}
static void initialize_sntp(void)
{
ESP_LOGI(SNTP_TAG, "Initializing SNTP");
sntp_setoperatingmode(SNTP_OPMODE_POLL);
sntp_setservername(0, "pool.ntp.org");
sntp_set_time_sync_notification_cb(time_sync_notification_cb);
sntp_init();
}
static bool obtain_time(void)
{
initialize_sntp();
ESP_ERROR_CHECK(esp_task_wdt_add(NULL));
memset(&timeinfo, 0, sizeof(struct tm));
int retry = 0;
const int retry_count = 10;
while (sntp_get_sync_status() == SNTP_SYNC_STATUS_RESET && ++retry <= retry_count)
{
ESP_LOGI(SNTP_TAG, "Waiting for system time to be set... (%d/%d)", retry, retry_count);
vTaskDelay(2000 / portTICK_PERIOD_MS);
ESP_ERROR_CHECK(esp_task_wdt_reset());
}
time(&now);
localtime_r(&now, &timeinfo);
ESP_ERROR_CHECK(esp_task_wdt_delete(NULL));
return timeinfo.tm_year > (2016 - 1900);
}
static void opc_event_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
if (timeinfo.tm_year < (2016 - 1900))
{
ESP_LOGI(SNTP_TAG, "Time is not set yet. Settting up network connection and getting time over NTP.");
if (!obtain_time())
{
ESP_LOGE(SNTP_TAG, "Could not get time from NTP. Using default timestamp.");
}
time(&now);
}
localtime_r(&now, &timeinfo);
ESP_LOGI(SNTP_TAG, "Current time: %d-%02d-%02d %02d:%02d:%02d", timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
if (!isServerCreated)
{
xTaskCreatePinnedToCore(opcua_task, "opcua_task", 24336, NULL, 10, NULL, 1);
ESP_LOGI(MEMORY_TAG, "Heap size after OPC UA Task : %d", esp_get_free_heap_size());
isServerCreated = true;
}
}
static void disconnect_handler(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
}
static void connection_scan(void)
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(esp_task_wdt_init(10, true));
ESP_ERROR_CHECK(esp_task_wdt_delete(xTaskGetIdleTaskHandleForCPU(0)));
ESP_ERROR_CHECK(esp_task_wdt_delete(xTaskGetIdleTaskHandleForCPU(1)));
#ifdef CONFIG_EXAMPLE_CONNECT_WIFI
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &opc_event_handler, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, WIFI_EVENT_STA_DISCONNECTED, &disconnect_handler, NULL));
#endif
#ifdef CONFIG_EXAMPLE_CONNECT_ETHERNET
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, &opc_event_handler, NULL));
ESP_ERROR_CHECK(esp_event_handler_register(ETH_EVENT, ETHERNET_EVENT_DISCONNECTED, &disconnect_handler, NULL));
#endif
ESP_ERROR_CHECK(example_connect());
}
至此,以上代码实现了一个时间同步应用,我们可以根据具体的应用场景将传感器的数据转化成OPC-UA协议,实现灵活的展示和控制。