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

前言

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

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

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

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

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

準備材料

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

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

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

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

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

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

一顆 100歐姆 的電阻

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

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

概念

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

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

再透過發射器送出頻率

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

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

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

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

09

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

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

硬體

08

從左至右分別是

  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,計時器套件,可以實現定時執行的需求

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

00

程式碼

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

#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 目錄

11

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

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

如果是手機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>

範例影片

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

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

12

可改進部分及可擴充部分

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

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

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

比如可以加個溫度傳感器

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

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

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

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

結尾

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

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

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

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

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

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

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

謝謝大家的觀看

Read more

[LeetCode] #12 Integer to Roman 解題

題目連結 題型解說 這是一題難度為普通的題目 需要設計一個方法,此方法會傳入一個整數 num 要求是把整數轉換成羅馬字母,轉換清單如下 I => 1 V => 5 X => 10 L => 50 C => 100 D => 500 M => 1000 但羅馬字母有一些特殊規則 4 並非 IIII 而是 IV,9 並非 VIIII 而是 IX 這規則同樣可以套用到 40 90 400 900 解題思路 既然知道特殊規則是一樣的,變得是使用的符號,那麼先從 num 取個位數開始 轉換完成後,把 num 除上 10,消除個位數,

By Michael

[LeetCode] #11 Container With Most Water 解題

題目連結 題型解說 這是一題難度為中等的題目 需要設計一個方法,此方法會傳入一個數字陣列 height 陣列中的元素代表每一個柱子的高度 現在需要計算出,該陣列中以某兩隻柱子為邊界,最多可以裝多少水 以範例來說 height = [1,8,6,2,5,4,8,3,7] 最多可以裝 7 * 7 = 49 單位的水 解題思路 計算面積就是底乘上高 底的計算方式為 「右邊柱子的 index」 減去 「左邊柱子的 index」 高就取最短的那一根柱子高度 拿題目給的例子來當範例 建立三個變數 result、left、right left、right 代表左右兩邊的 index result 代表目前最大容量,初始值 0 第一步,找出最短的柱子高度,

By Michael

[LeetCode] #941 Valid Mountain Array 解題

題目連結 題型解說 這是一題難度為簡單的題目 需要設計一個方法,此方法會傳入一個數字陣列 arr 判斷陣列中的元素是不是由低到高再從高到低(山形)的排序,且不連續一個以上數字 比如說 [1,2,3,2] 就是一個山形陣列,但 [1,2,2,3,2] 不是,因為有兩個 2 [1,2,3,4,5] 和 [5,4,3,2,1] 也不算是山形陣列,前者只有往上沒有往下,後者相反 解題思路 準備一個數字變數(temp)和布林變數(asc),跑一次迴圈,有可能遇到如下狀況 1. 某個數字與前一個數字相同,這時候直接回傳 false

By Michael

[LeetCode] #944 Delete Columns to Make Sorted 解題

題目連結 題型解說 這是一題難度為簡單的題目 需要設計一個方法,此方法會傳入一個字串陣列 strs 這個陣列中每個字串的長度都相同,字串內容都是小寫英文 需要檢查每個元素的第 N 個字元是不是由小至大排列,並回傳有幾個錯誤排列 比如傳入的陣列長這樣 ["cba","daf","ghi"] 取第一個字元 = cdg 取第二個字元 = bah 取第三個字元 = afi 其中第二組的結果(bah)並不是由小至大排列,故回傳 1 解題思路 這一題就用兩個迴圈各別把字元取出來,並比較是否比上一個字元大(Java 中的字元可以直接比較),如果不是就將結果+1 程式碼 Java class Solution { public int minDeletionSize(String[] strs) { int result = 0; for (int i = 0,

By Michael