[Arduino]開發智慧型家電,紅外線篇

前言

在一個非常炎熱的夏天,下班回到家等待自己的是30度的高溫

除了眼神死依然還是眼神死,只能馬上把冷氣打開

那麼有沒有辦法在回家的路上就把冷氣給打開呢??

今天這篇文章就是分享給大家我是怎麼製作自己的智慧型家電

我是一個新手Arduino玩家,文章內容若有錯誤請多多包涵

準備材料

一顆紅外線接收器,台灣普遍家電遙控器使用的是 38KHz 的頻率,購買前請先確認好

一顆紅外線發射器,一樣要確認可以發射 38KHz 的頻率

兩顆 LED,我選擇買高亮綠光,可以直插3.3V

其他顏色的可能要確認是不是需要加電組

兩顆復位開關,開關就買自己喜歡的就好

非常好用的杜邦線,公+公 公+母 各一包

一顆 100歐姆 的電阻

一顆 NodeMCU CH340G,直接實現WIFI功能

以上價格請自行參考,我是在實體店買的,網路可能會比較便宜

概念

平常網路上搜尋到的紅外線教學

幾乎都是教你先利用接收器接收遙控器的紅外線頻率,再把數值寫死在程式碼中

再透過發射器送出頻率

這樣一來,整個裝置的靈活性會略顯不足

所以這次打算利用動態的方式來記錄紅外線頻率

簡單來說就是在不重新燒錄程式的形況下,更改需要的紅外線頻率

整體程式的流程圖大概如下(邏輯判斷往右邊方向的都是否定)

流程圖內有兩個按鈕,分別用來紀錄開啟及關閉的紅外線頻率

稍微理解過後我們就開始實作吧

硬體

從左至右分別是

  1. 紅外線接收器,左邊腳位接到板子的 D1 腳位,中間接地,右邊腳位接到 3.3V
  2. 紅外線發射器,正極接上 100歐姆 電阻後接到板子的 D2 腳位,負極接地
  3. 狀態一LED燈,正極接到板子的 D5 腳位,負極接地
  4. 狀態一按鈕,一個腳位接到板子的 D3 腳位,另一個腳位接地
  5. 狀態二LED燈,正極接到板子的 D6 腳位,負極接地
  6. 狀態二按鈕,一個腳位接到板子的 D4 腳位,另一個腳位接地

紅外線發射器不加電阻的下場就是...一股燒焦味發出

Arduino IDE 相關套件安裝

NodeMCU並不是隨插即用的,需要安裝一些東西

若第一次使用這張開發版可看這一篇 [Arduino]Arduino IDE 使用 NodeMCU

另外我們還需要一些套件,下載後放到 Arduino IDE 安裝目錄下的 libraries 目錄下

IRremoteESP8266,ESP8266使用的紅外線套件

Timer,計時器套件,可以實現定時執行的需求

記得將目錄名稱更改成以下圖片中的樣子

程式碼

有一部分是直接使用套件的程式碼範例,所以風格上有些不同

#include <Arduino.h>
#include <IRremoteESP8266.h>
#include <IRsend.h>
#include <IRrecv.h>
#include <IRac.h>
#include <IRutils.h>
#include <ESP8266WiFi.h>
#include <ESP8266WiFiMulti.h>
#include <ESP8266HTTPClient.h>
#include <Timer.h>

/* 紅外線發射 */
const uint16_t kIrLed = D2;
IRsend irsend(kIrLed);

/* 紅外線接收 */
const uint16_t kRecvPin = D1;
const uint16_t kCaptureBufferSize = 1024;
#if DECODE_AC
    const uint8_t kTimeout = 50;
#else
    const uint8_t kTimeout = 15;
#endif
const uint16_t kMinUnknownSize = 12;
IRrecv irrecv(kRecvPin, kCaptureBufferSize, kTimeout, true);
decode_results results;

/* 流程控制 */
const int offButtonPin = D3;
const int onButtonPin = D4;
const int offLedPin = D5;
const int onLedPin = D6;
uint8_t count = 0;
uint8_t statusCode = 0;

/* WIFI */
#ifndef STASSID
    #define STASSID "WIFI的SSID"
    #define STAPSK  "WIFI的密碼"
#endif
char* ssid = STASSID;
char* password = STAPSK;
ESP8266WiFiMulti WiFiMulti;

/* 定時器 */
Timer t1;

/* 資訊變數 */
#ifndef MACADD
    #define MACADD WiFi.macAddress() //用來當作唯一識別值
#endif
char* host = "遠端伺服器IP或網址(記得加上 http:// 或 https://)";
uint16_t port = 80; //一般網頁伺服器都是80port,視情況需要自行修改

void setup() {
    irsend.begin();
    Serial.begin(115200);

    #if DECODE_HASH
        irrecv.setUnknownThreshold(kMinUnknownSize);
    #endif
        irrecv.enableIRIn();

    pinMode(offButtonPin, INPUT);
    pinMode(onButtonPin, INPUT);

    pinMode(offLedPin, OUTPUT);
    pinMode(onLedPin, OUTPUT);

    WiFi.mode(WIFI_STA);
    WiFiMulti.addAP(ssid, password);

    while (WiFiMulti.run() != WL_CONNECTED) {
        delay(500);
    }
    
    t1.every(5000, chk_action); //五秒一次詢問伺服器有沒有指令需要執行
}

/*
    送出紅外線頻率到伺服器儲存
*/
void send_data(char* type, uint16_t irr[], int irr_len) {
    char* result;
    
    if (join(result, ",", irr, irr_len) < 0) {
        return;
    }

    char* path;
    char* response;
    
    if (type == "off") { //紀錄關閉的紅外線頻率
        path = "/safe_ir_off.php";
    }

    if (type == "on") { //紀錄開啟的紅外線頻率
        path = "/safe_ir_on.php";
    }

    char* url;
    if(NULL != (url = (char*)malloc(strlen(host) + strlen(path) +1))){
        url[0] = '\0';
        
        strcat(url,host);
        strcat(url,path);
    } else {
        return;
    }

    HTTPClient http;

    http.begin(url);

    http.addHeader("Content-Type", "application/json"); //json格式幾乎所有後端程式都可以支援

    http.POST("{\"k\": \"" + MACADD + "\", \"v\":\"" + result + "\"}");

    http.writeToStream(&Serial);

    http.end();
    
    free(url);
    free(result);
}

int bytes_added(int result_of_sprintf) {
    return (result_of_sprintf > 0) ? result_of_sprintf : 0;
}

/*
    將數字陣列轉成文字
*/
int join(char*& result, char separator[], uint16_t data[], int loop_len) {
    if (loop_len == 1) {
        result = (char*)malloc(sizeof(uint16_t));

        if (NULL == result) {
            return -1;
        }

        sprintf(result, "%d", data[0]);
        
        return 0;
    }

    int result_len = loop_len * 5 + ((loop_len -1) * strlen(separator)) + 1;
    result = (char*)malloc(result_len);
    loop_len -= 1;

    if (NULL == result) {
        return -1;
    }

    int i;
    int length = 0;
    for (i = 0; i < loop_len; i++) {
        length += bytes_added(sprintf(result + length, "%d%s", data[i], separator));
    }

    sprintf(result + length, "%d", data[loop_len]);

    return 0;
}

/*
    檢查有沒有待執行的指令
*/
void chk_action(){
    char* path = "/chk_action.php?k=";
    char* url;
    
    if(NULL != (url = (char*)malloc(strlen(host) + strlen(path) +1))){
        url[0] = '\0';
        
        strcat(url,host);
        strcat(url,path);
    } else {
        return;
    }
    
    HTTPClient http;

    http.begin(url + MACADD);

    int httpCode = http.GET();

    if (httpCode == 200) {
        const char* data = http.getString().c_str();
        uint16_t *rawData;
        int len = 0;

        if ((len = split(rawData, data)) < 0) {
            return;  
        }

        irsend.sendRaw(rawData, len, 38); //發射紅外線訊號
        
        free(rawData);
    }

    http.end();

    free(url);
}

/*
    將文字切割成數字陣列
*/
int split(uint16_t* &result, const char* data) {
    int len = strlen(data);
    int count = 1;

    for (int i = 0; i < len; i++) {
        if (data[i] == ',') {
            ++count;
        }
    }

    if (count == 1) {
        return -1;
    }
    
    result = (uint16_t*)malloc(sizeof(uint16_t) * count);

    if (NULL == result) {
        return -1;
    }

    int index = 0;
    int sum = 0;

    for (int i = 0; i < len; i++) {
        switch (data[i]) {
            case '0':
                sum = sum * 10;
                break;
            case '1':
                sum = sum * 10 + 1;
                break;
            case '2':
                sum = sum * 10 + 2;
                break;
            case '3':
                sum = sum * 10 + 3;
                break;
            case '4':
                sum = sum * 10 + 4;
                break;
            case '5':
                sum = sum * 10 + 5;
                break;
            case '6':
                sum = sum * 10 + 6;
                break;
            case '7':
                sum = sum * 10 + 7;
                break;
            case '8':
                sum = sum * 10 + 8;
                break;
            case '9':
                sum = sum * 10 + 9;
                break;
            default:
                if (sum > 65535) {
                    free(result);
                    return -1;
                }
                if (data[i] == ',') {
                    *(result + index) = (uint16_t)sum;
                    ++index;
                    sum = 0;
                }
        }
        if (sum > 65535) {
            free(result);
            return -1;
        }
    }

    if (sum > 65535) {
        free(result);
        return -1;
    }

    *(result + index) = (uint16_t)sum;
    return count;
}

void loop() {
    /*
        接收紅外線訊號狀態
    */
    if (statusCode == 1 || statusCode == 2) {
        if (statusCode == 1) {
            digitalWrite(offLedPin, LOW);
            delay(500);
            digitalWrite(offLedPin, HIGH);
            delay(500);  
        }

        if (statusCode == 2) {
            digitalWrite(onLedPin, LOW);
            delay(500);
            digitalWrite(onLedPin, HIGH);
            delay(500);  
        }

        if (irrecv.decode(&results)) {
            if (results.overflow)
                Serial.printf(
                    "WARNING: IR code is too big for buffer (>= %d). "
                    "This result shouldn't be trusted until this is resolved. "
                    "Edit & increase kCaptureBufferSize.\n",
                    kCaptureBufferSize);
    
            int len = getCorrectedRawLength(&results);
            uint32_t index = 0;
            uint16_t irr[len];
    
            for (int i = 1, len2 = (&results)->rawlen; i < len2; i++) {
                uint32_t usecs;
    
                for (usecs = (&results)->rawbuf[i] * kRawTick; usecs > UINT16_MAX; usecs -= UINT16_MAX) {
                    irr[index] = UINT16_MAX;
                    index += 1;
                    irr[index] = 0;
                    index += 1;
                }
    
                irr[index] = usecs;
                index += 1;
            }
            
            yield();
    
            if (statusCode == 1) {
                send_data("off", irr, sizeof(irr) / sizeof(uint16_t));
                digitalWrite(offLedPin, LOW);
            } else {
                send_data("on", irr, sizeof(irr) / sizeof(uint16_t));
                digitalWrite(onLedPin, LOW);
            }
            
            statusCode = 0;
        }
        
        return;
    }

    if (digitalRead(offButtonPin) == LOW) {
        count += 1;
        delay(500);
        if (count == 3) {
            statusCode = 1;
            count = 0;
            return;
        }
    } else if (digitalRead(onButtonPin) == LOW) {
        count += 1;
        delay(500);
        if (count == 3) {
            statusCode = 2;
            count = 0;
            return;
        }
    } else {
        count = 0;
    }

    t1.update();
}

伺服器端程式碼

伺服器的語言我選擇使用 PHP,最主要的原因就是簡單方便

而且許多免費的網路空間幾乎都支持 PHP

不知道怎麼架站的開發者可以參考這篇文章

[PHP]免費虛擬空間申請教學

以下將會牽扯到網頁後端開發的領域,
若對這不熟悉的讀者可以直接複製程式碼使用

接下來我們會需要四隻PHP檔案

  1. index.php 使用者首頁,會在這頁面控制設備
  2. chk_action.php 讓設備查詢有無待執行的指令
  3. safe_ir_off.php 將設備送來的紅外線頻率(關閉)記錄起來
  4. safe_ir_on.php 將設備送來的紅外線頻率(開啟)記錄起來

並且在根目錄新增一個 data 目錄

因為後端邏輯相對簡單,所以我就不另外畫流程圖了,直接文字敘述

一開始遇到的難題就是,後端並不知道傳送過來的資料是屬於哪一個設備

如果是手機APP,可以直接用手機的唯一識別碼來當作Key

但偏偏這塊板子並沒有這種東西...

最後左思右想,想到了網卡的 MAC地址 也是每張不一樣且唯一的(理論上)

那麼就利用 MAC地址 當作設備的Key,儲存的方式選擇最簡單的寫入Json檔

Json檔中每一個Key就是一個指令,待執行指令會寫在 action 中

safe_ir_on.php

<?php
	$input = json_decode(file_get_contents('php://input'), true);

	/*
		檢查必要參數
	*/
	if (isset($input['k']) === false || isset($input['v']) === false) {
		exit;
	}

	$k = str_replace(':', '-', trim($input['k']));
	$v = trim($input['v']);

	/*
		排除空資料
	*/
	if ($k == '' || $v == '') {
		exit;
	}

	/*
		獲取資料
	*/
	$path = 'data/'.$k;

	if (file_exists($path) === true) {
		try {
			$data = json_decode(file_get_contents($path), true);
		} catch (Exception $e) {
			$data = [];
		}
	} else {
		$data = [];
	}

	/*
		寫入紅外線頻率(開啟)
	*/
	$data['on'] = $v;

	file_put_contents($path, json_encode($data));
?>

safe_ir_off.php

<?php
	$input = json_decode(file_get_contents('php://input'), true);

	/*
		檢查必要參數
	*/
	if (isset($input['k']) === false || isset($input['v']) === false) {
		exit;
	}

	$k = str_replace(':', '-', trim($input['k']));
	$v = trim($input['v']);

	/*
		排除空資料
	*/
	if ($k == '' || $v == '') {
		exit;
	}

	/*
		獲取資料
	*/
	$path = 'data/'.$k;

	if (file_exists($path) === true) {
		try {
			$data = json_decode(file_get_contents($path), true);
		} catch (Exception $e) {
			$data = [];
		}
	} else {
		$data = [];
	}

	/*
		寫入紅外線頻率(關閉)
	*/
	$data['off'] = $v;

	file_put_contents($path, json_encode($data));
?>

chk_action.php

<?php
	/*
		設備利用 200 狀態碼判定是否執行

		故不須執行時回傳 200 以外的狀態碼
	*/
	function stop() {
		header('Status: 400 Bad Request');
		exit;
	}

	/*
		檢查必要參數
	*/
	if (isset($_GET['k']) === false) {
		stop();
	}

	$k = str_replace(':', '-', trim($_GET['k']));

	/*
		檢查資料存在
	*/
	$path = 'data/'.$k;

	if (file_exists($path) === false) {
		stop();
	}

	try {
		/*
			獲取資料
		*/
		$data = json_decode(file_get_contents($path), true);

		if (isset($data['action']) === false || $data['action'] == '') {
			stop();
		}

		$v = $data['action'];

		/*
			清空指令後存檔
		*/
		$data['action'] = '';

		file_put_contents($path, json_encode($data));

		/*
			回傳資料給設備
		*/
		echo $v;
	} catch (Exception $e) {
		stop();
	}
?>

index.php

<?php
	/*
		獲取設備資料
	*/
	$list = [];

	foreach (glob("data/*") as $file_name) {
		try {
			$data = json_decode(file_get_contents($file_name), true);
			$name = str_replace('-', ':', basename($file_name));
			foreach ($data as $key => $value) {
				if ($key == 'action') {
					continue;
				}

				$list[$name][] = $key;
			}
			
			rsort($list[$name]);
		} catch (Exception $e) {
			
		}
		
	}

	/*
		寫入指令
	*/
	if (isset($_POST['type']) && isset($_POST['k'])) {
		$k = str_replace(':', '-', $_POST['k']);

		/*
			獲取資料
		*/
		$path = 'data/'.$k;

		if (file_exists($path) === true) {
			try {
				$data = json_decode(file_get_contents($path), true);
			
				if (isset($data[ $_POST['type'] ])) {
					$data['action'] = $data[ $_POST['type'] ];

					file_put_contents($path, json_encode($data));
				}
			} catch (Exception $e) {
				
			}
		}
	}
?>
<!DOCTYPE html>
<html>
	<head>
		<title>智慧型家電控制面板</title>

		<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">

		<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
		<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
		<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
	</head>
	<body>
		<div class="container">
			<div class="row justify-content-around">
				<div class="col-8">
					<table class="table">
						<thead>
							<tr>
								<th scope="col">設備ID</th>
								<th scope="col">功能</th>
							</tr>
						</thead>
						<tbody>
							<?php foreach ($list as $id => $func): ?>
								<tr>
									<th scope="row"><?php echo $id; ?></th>
									<td>
										<form method="POST">
											<input type="hidden" name="k" value="<?php echo $id; ?>" />
											<?php foreach ($func as $name): ?>
												<button type="submit" class="btn btn-success" name="type" value="<?php echo $name; ?>"><?php echo $name; ?></button>
											<?php endforeach; ?>
										</form>
									</td>
								</tr>
							<?php endforeach; ?>
						</tbody>
					</table>
				</div>
			</div>
		</div>
	</body>
</html>

範例影片

可以看到紅外線頻率變成一連串的數字儲存在網站內

這麼一來,設備就可以隨時的替換掉紅外線頻率,而且不需要重新燒錄,非常的方便

可改進部分及可擴充部分

這是一個非常簡單的小作品,使用到的材料不多

功能也很陽春,只是簡單的開啟或關閉

但是可以擴充的部分也不少

比如可以加個溫度傳感器

在室溫上升至XX度時開啟冷氣、下降到XX度時關閉冷氣

又比如現在是用兩顆按鈕來代表兩種狀態,可以加一塊螢幕顯示器

並且修改一下程式碼,利用螢幕顯示的方式讓使用者自訂狀態名稱來記錄頻率

有各式各樣的想法可以去實現看看,Arduino真的很好玩

結尾

這是我第一次從頭到尾完成整個作品,也是一個很好玩的經驗

這期間經歷了很多難關,但都一一克服了,成就感十足

也非常感謝社團、群組、朋友們的幫忙

另外在這邊也建議讀者們可以去嘗試學習一下網站開發

因為硬體搭配上軟體可以讓整個侷限性大大擴張

讓一個設備更好的融入到生活上

若這邊教學有幫助到你的話~請多多分享轉發出去給更多的人知道

謝謝大家的觀看