티스토리 뷰
[목차]
- Python Code
- Arduino Code
* 소스 코드 : OTA264V011_Pyduino260408W.zip
[Python]
# pip3 install Flask flask-sqlalchemy apscheduler waitress requests python-dotenv --break-system-packages
import os
from flask import Flask, request, jsonify, render_template, send_file, redirect, url_for, session
from werkzeug.utils import secure_filename
from models import db, Device, Firmware, OtaTask, AutoRule, CompileJob
from compiler import background_compile
from concurrent.futures import ThreadPoolExecutor
from apscheduler.schedulers.background import BackgroundScheduler
import time
import shutil
import sys
from dotenv import load_dotenv
FOLDERPATH_COMMONLIBRARIES = "/root/commonlibs"
sys.path.append(FOLDERPATH_COMMONLIBRARIES)
envPath = FOLDERPATH_COMMONLIBRARIES + "/.env"
load_dotenv(envPath)
from BMessenger import bMessenger
from BTime import bTime
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__)
# 보안: 실제 환경에서는 랜덤 키 사용
app.config['SECRET_KEY'] = 'termux-ota-key264'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(BASE_DIR, 'ota.db')
app.config['UPLOAD_FOLDER_BIN'] = os.path.join(BASE_DIR, 'firmwares', 'uploads')
app.config['UPLOAD_FOLDER_ZIP'] = os.path.join(BASE_DIR, 'firmwares', 'sources')
DIR_BUILDS = os.path.join(BASE_DIR, 'firmwares', 'builds')
DIR_COMPILED = os.path.join(BASE_DIR, 'firmwares', 'compiled')
for folder in [app.config['UPLOAD_FOLDER_BIN'], app.config['UPLOAD_FOLDER_ZIP'], DIR_BUILDS, DIR_COMPILED]:
os.makedirs(folder, exist_ok=True)
db.init_app(app)
with app.app_context():
db.create_all()
# ==== 빌드된 파일 쓰레기 수집기 (Garbage Collector) ====
def cleanup_old_files():
print("[Garbage Collector] 30분 이상 경과된 임시 폴더 및 bin 정리 스캔...")
now = time.time()
for directory in [DIR_BUILDS, DIR_COMPILED]:
if not os.path.exists(directory): continue
for filename in os.listdir(directory):
filepath = os.path.join(directory, filename)
# 3600초 (1시간) 초과 시 삭제
if os.path.getmtime(filepath) < now - 1800:
try:
if os.path.isdir(filepath):
shutil.rmtree(filepath)
else:
os.remove(filepath)
print(f"[Garbage Collector] 삭제됨: {filepath}")
except Exception as e:
print(f"[Garbage Collector] 삭제 실패 {filepath}: {e}")
# 스케쥴러 생성 및 시작 (프로세스 종료 시까지 백그라운드 구동)
scheduler = BackgroundScheduler()
scheduler.add_job(func=cleanup_old_files, trigger="interval", seconds=600) # 10분마다 스캔
scheduler.start()
# 스마트폰 과부하 방지를 위해 컴파일 스레드 최대 개수 제한 (2개)
compile_executor = ThreadPoolExecutor(max_workers=2)
def login_required(f):
def wrapper(*args, **kwargs):
if not session.get('is_admin'): return redirect(url_for('login'))
return f(*args, **kwargs)
wrapper.__name__ = f.__name__
return wrapper
@app.route('/')
def index():
return redirect(url_for('admin'))
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
adminPW = os.getenv('PW_OTA264_ADMIN')
# 아주 단순한 하드코딩 인증 (보안 요구사항 충족)
if request.form['id'] == 'admin' and request.form['pw'] == adminPW:
session['is_admin'] = True
return redirect(url_for('admin'))
return "인증 실패"
return '''<form method="post">ID: <input name="id"><br>PW: <input type="password" name="pw"><br><button>로그인</button></form>'''
@app.route('/admin')
@login_required
def admin():
devices = Device.query.all()
return render_template('admin.html', devices=devices) # Admin UI를 분리된 템플릿으로 제공
# ==== [방식 1] 관리자 전용 bin 파일 업로드 (보안) ====
@app.route('/admin/upload_bin', methods=['POST'])
@login_required
def upload_bin():
file = request.files.get('file')
if not file: return "파일 없음", 400
fname = secure_filename(file.filename)
if not fname.endswith('.bin'): return "bin 확장자만 허용", 400
path = os.path.join(app.config['UPLOAD_FOLDER_BIN'], fname)
file.save(path)
# DB 저장
fw_name = request.form.get('name', fname)
new_fw = Firmware(name=fw_name, file_path=path)
db.session.add(new_fw)
db.session.commit()
# deviceType이 기입된 경우, 관련된 모든 등록 장치에 수동 할당 큐(Task)를 자동 생성
device_type = request.form.get('device_type')
if device_type and device_type.strip() != "":
devices = Device.query.filter_by(type=device_type).all()
for d in devices:
old_tasks = OtaTask.query.filter(OtaTask.device_mac==d.mac, OtaTask.status.in_(['WAITING', 'IN_PROGRESS'])).all()
for ot in old_tasks: ot.status = 'CANCELED'
task = OtaTask(device_mac=d.mac, firmware_id=new_fw.id, status='WAITING')
db.session.add(task)
db.session.commit()
return f"방식1 펌웨어({fw_name}) 업로드 및 {len(devices)}대({device_type})에 일괄 배포 예약 완료!"
return f"방식1 펌웨어({fw_name}) 수동 업로드 완료 (기본: 패널 3에서 수동 배정)"
# ==== [방식 1] 사용자가 기기에 특정 펌웨어 매핑 (Task Queue 생성) ====
@app.route('/user/assign', methods=['POST'])
def assign_firmware():
# 사용자가 웹에서 자신의 기기 MAC과 원하는 기능 번호를 입력했다 가정
mac = request.form['mac']
fw_id = request.form['fw_id']
old_tasks = OtaTask.query.filter(OtaTask.device_mac==mac, OtaTask.status.in_(['WAITING', 'IN_PROGRESS'])).all()
for ot in old_tasks: ot.status = 'CANCELED'
task = OtaTask(device_mac=mac, firmware_id=fw_id, status='WAITING')
db.session.add(task)
db.session.commit()
return "펌웨어가 할당 완료되었습니다. (장치가 접속 시 다운로드됩니다)"
# ==== [방식 2] 관리자 전용 ino 소스코드 zip 패키지 업로드 ====
@app.route('/admin/upload_zip', methods=['POST'])
@login_required
def upload_zip():
file = request.files.get('file')
if not file: return "파일 없음", 400
fname = secure_filename(file.filename)
if not fname.endswith('.zip'): return "zip 확장자만 허용", 400
path = os.path.join(app.config['UPLOAD_FOLDER_ZIP'], fname)
file.save(path)
device_type = request.form['device_type']
target_version = request.form['version']
# Rule 생성 (새 버전으로 갱신)
rule = AutoRule.query.filter_by(device_type=device_type).first()
if rule:
rule.latest_version = target_version
rule.source_zip_path = path
else:
db.session.add(AutoRule(device_type=device_type, latest_version=target_version, source_zip_path=path))
db.session.commit()
return f"방식2 규칙({device_type} -> {target_version}) 생성 완료!"
# ==== [API] 아두이노 접속용 엔드포인트 ====
DEVICE_API_KEY = "SECRET_DEVICE_KEY_2026"
def check_device_auth(req):
return req.headers.get('x-api-key') == DEVICE_API_KEY
def register_device_db(mac, dtype=None):
if not mac: return
device = Device.query.filter_by(mac=mac).first()
if not device:
db.session.add(Device(mac=mac, type=dtype))
db.session.commit()
elif dtype and device.type != dtype:
device.type = dtype
db.session.commit()
@app.route('/api/ota/manual', methods=['GET'])
def ota_manual():
if not check_device_auth(request):
return "Unauthorized", 401
mac = request.args.get('mac')
register_device_db(mac)
# WAITING (1번째 접근 - 헤더 확인) 또는 IN_PROGRESS (재시도 및 다운로드)
task = OtaTask.query.filter(OtaTask.device_mac==mac, OtaTask.status.in_(['WAITING', 'IN_PROGRESS'])).first()
if task:
fw = db.session.get(Firmware, task.firmware_id)
if fw and os.path.exists(fw.file_path):
if task.status == 'WAITING':
# 첫 접속 시도 시 IN_PROGRESS 로만 변경. (장부를 여기서 닫지 않음)
task.status = 'IN_PROGRESS'
db.session.commit()
# IN_PROGRESS 상태에서는 언제든, 몇 번이든 실패 후 재시도해도 파일을 보내줌
safe_path = os.path.abspath(os.path.join(BASE_DIR, fw.file_path) if not os.path.isabs(fw.file_path) else fw.file_path)
return send_file(safe_path, as_attachment=True)
return "No manual task", 304
@app.route('/api/ota/success', methods=['GET'])
def ota_success():
""" 아두이노가 플래시 쓰기를 완벽히 성공하고 재부팅 직전에 호출하여 장고를 DONE 마감하는 곳 """
if not check_device_auth(request): return "Unauthorized", 401
mac = request.args.get('mac')
task = OtaTask.query.filter_by(device_mac=mac, status='IN_PROGRESS').first()
if task:
task.status = 'DONE'
db.session.commit()
return "Complete", 200
return "None", 304
@app.route('/api/ota/auto', methods=['GET'])
def ota_auto():
if not check_device_auth(request):
return "Unauthorized", 401
mac = request.args.get('mac')
dtype = request.args.get('type')
version = request.args.get('version')
register_device_db(mac, dtype)
rule = AutoRule.query.filter_by(device_type=dtype).first()
if not rule or version == rule.latest_version:
return "No auto update needed", 304
# 현재 장치/버전에 매칭되는 컴파일 작업이 있는지 점검
job = CompileJob.query.filter_by(device_mac=mac, target_version=rule.latest_version).first()
if not job:
# DB에 컴파일 작업 큐 생성 후 백그라운드 구동!
new_job = CompileJob(device_mac=mac, target_version=rule.latest_version)
db.session.add(new_job)
db.session.commit()
# ThreadPool을 사용한 비동기 백그라운드 컴파일 (Waitress 블로킹 회피)
compile_executor.submit(
background_compile,
app, db, CompileJob, new_job.id, rule.source_zip_path, mac, rule.latest_version
)
return jsonify({"status": "compiling", "retry_after": 30}), 202
elif job.status == 'COMPILING':
# 아직 Waitress 스레드 외부에서 컴파일이 도는 중
return jsonify({"status": "progress", "retry_after": 30}), 202
elif job.status == 'DONE':
if job.bin_path and os.path.exists(job.bin_path):
return send_file(job.bin_path, as_attachment=True)
return "Bin file lost", 404
elif job.status == 'FAIL':
return "Compile failed", 500
if __name__ == '__main__':
from waitress import serve
# Termux 환경의 비루트(Non-Root) 계정 실행을 고려하여 80포트 대신 26410 바인딩
print("Waitress Server is walking on localhost:26410")
message1 = "[!] OTA264V01 Started. @ " + bTime.GetTimeString()
bMessenger.SendTelegram(message1)
serve(app, host='0.0.0.0', port=26410)
[Arduino]
#include <WiFi.h>
#include <WiFiMulti.h>
#include <HTTPClient.h>
#include <Update.h>
#include <WiFiManager.h> // ESP32 AP Provisioning (캡티브 포털용)
#include <Preferences.h> // 비휘발성 플래시 메모리(NVS) 저장소
// 분리된 서드파티 라이브러리 및 소스를 테스트하기 위한 헤더
#include "sensor.h"
// ==== [보안구역] 초기 비상용 핫스팟 (배포 시 라인 전체 삭제 가능) ====
//#define DEFAULT_SSID "default-ssid"
//#define DEFAULT_PW "default-pw"
// =======================================================
#define FLAG_FACTORY_RESET false
// 서버 통신 설정
const String ota_server_domain = "http://192.168.0.87:26410";
const String ota_server_auto = ota_server_domain + "/api/ota/auto";
const String ota_server_manual = ota_server_domain + "/api/ota/manual";
String deviceMAC = "";
String deviceType = "BiliMon264";
String currentVersion = "V1.5"; // 서버에 상위 버전을 등록하면 변경을 감지합니다.
const String API_KEY = "SECRET_DEVICE_KEY_2026";
// [환경 변수 복원 플래그] true: 과거 NVS 메모리 유지 / false: 이하의 새 코드값으로 강제 덮어쓰기
bool reloadFlag = true;
int myStateVal = 0; // 테스트용: 업데이트 이력 보존용 카운터
// [하드웨어 공장초기화 플래그] (주의!) true로 두고 코드를 업로드하면 부팅 직후 모든 기억을 포맷시킵니다.
bool DO_FORMAT_NVS = FLAG_FACTORY_RESET;
unsigned long lastPollTime = 0;
unsigned long pollInterval = (300L*1000);
Sensor MySensor;
Preferences preferences;
WiFiMulti wifiMulti;
#define BOOT_BUTTON 0 // ESP32 기본 시스템 BOOT 버튼 (GPIO 0)
int outCount = 1;
int outMultiplier = 5;
void setup() {
Serial.begin(115200);
delay(1000);
pinMode(BOOT_BUTTON, INPUT_PULLUP);
// 1. Preferences(NVS) 초기화
preferences.begin("ota_app", false);
// ---------------- [2. 공장초기화 감지 트리거 (수동/자동)] ----
// [A] 코딩으로 강제 지시 (DO_FORMAT_NVS)
if (DO_FORMAT_NVS == true) {
clearAllNVSData();
// clearAllNVSData() 내부에서 무한 루프에 빠지므로 이후 코드 진행 안 됨.
}
// [B] 물리 버튼으로 강제 지시 (BOOT 3초간 누름 -> AP모드 전환용)
Serial.println("\n[시스템] 공유기 비밀번호를 바꾸고 싶으시다면(AP모드 전환) 지금 BOOT 버튼을 3초간 누르세요...");
delay(100);
if (digitalRead(BOOT_BUTTON) == LOW) {
long startTime = millis();
bool resetTriggered = true;
while(millis() - startTime < 3000) {
if (digitalRead(BOOT_BUTTON) == HIGH) {
resetTriggered = false;
break;
}
delay(10);
}
if (resetTriggered) {
switchToAPMode(); // 내부 데이터는 살리고 WiFi만 지우는 전용 함수 호출
}
}
// --------------------------------------------------------
// ---------------- [3. 변수 리로드 로직] --------------------
if (reloadFlag == true) {
myStateVal = preferences.getInt("my_state", myStateVal);
Serial.printf("\n[NVS] 예전 데이터 복원 완료 (유지 모드 작동): %d\n", myStateVal);
} else {
preferences.putInt("my_state", myStateVal);
Serial.printf("\n[NVS] 새로운 코드값으로 데이터 덮어쓰기 완료 (초기화 모드 작동): %d\n", myStateVal);
}
// --------------------------------------------------------
// ---------------- [4. 무선망 신원정보 로드 및 매크로 갱신] --
String fb_ssid = preferences.getString("fb_ssid", "");
String fb_pw = preferences.getString("fb_pw", "");
String user_ssid = preferences.getString("user_ssid", "");
String user_pw = preferences.getString("user_pw", "");
// 전처리기를 통한 삭제 가능형 하드코딩 와이파이 투입
#ifdef DEFAULT_SSID
String code_ssid = DEFAULT_SSID;
#else
String code_ssid = "";
#endif
#ifdef DEFAULT_PW
String code_pw = DEFAULT_PW;
#else
String code_pw = "";
#endif
// 코드에 기본값이 적혀있다면 (삭제되지 않았다면) 플래시의 기존 정보를 이것으로 강제 갱신합니다.
if (code_ssid != "") {
fb_ssid = code_ssid;
fb_pw = code_pw;
preferences.putString("fb_ssid", fb_ssid);
preferences.putString("fb_pw", fb_pw);
Serial.println("[NVS] 소스코드 내 고정 핫스팟이 감지되어 플래시에 영구 내재화(오버라이드) 되었습니다.");
} else {
Serial.println("[NVS] 소스코드에서 고정 핫스팟이 지워진 상태입니다. 플래시 메모리에서만 가져옵니다.");
}
// --------------------------------------------------------
// ---------------- [5. WiFiMulti 다중 후보군 등록 및 스캔] ---
Serial.println("\n[WiFiMulti] 접속 후보들을 배터리에 장전합니다...");
if (fb_ssid != "") {
wifiMulti.addAP(fb_ssid.c_str(), fb_pw.c_str());
Serial.println(" -> 고정/비상망 장전 완료: " + fb_ssid);
}
if (user_ssid != "") {
wifiMulti.addAP(user_ssid.c_str(), user_pw.c_str());
Serial.println(" -> 사용자 동적망 장전 완료: " + user_ssid);
}
// 연결 시도 (약 10초 대기하며 후보 중 가장 강한 놈 탐색)
Serial.println("[WiFiMulti] 주변 AP 스캐닝 및 통신망 연결 시도 중... (최대 10초)");
bool connected = false;
int attempts = 0;
while(attempts < 10) { // 1초씩 10번 = 약 10초
if(wifiMulti.run() == WL_CONNECTED) {
connected = true;
break;
}
delay(1000);
attempts++;
Serial.print(".");
}
// --------------------------------------------------------
// ---------------- [6. 연결 실패 시 AP 설정 모드 비상 탈출] -
if (!connected) {
Serial.println("\n\n[장애] 등록된 두 와이파이를 모두 방어하지 못했거나 암호가 틀렸습니다!");
Serial.println("[WiFiManager] 스마트폰 설정을 위한 AP(BiliMon_Setup) 강제 진입합니다.");
WiFiManager wm;
// 만약 사용자가 웹 캡티브 포털에서 새로운 AP를 던져주면 접속 시도
if (!wm.startConfigPortal("BiliMon_Setup")) {
Serial.println("[WiFiManager] 세팅창 켜짐 대기시간 초과 (보드 재시작)");
delay(3000);
ESP.restart();
}
// 이 구문에 도달했다면 무사히 웹 포털에서 암호를 입력받고 붙은 상태임
String new_user_ssid = WiFi.SSID();
String new_user_pw = WiFi.psk();
// (선택 사항) WiFiManager가 내부적으로 오만가지 잡동사니 AP 이력을 쌓는 것을 방지하기 위해
// 여기서 수동으로 1개만 user_ssid 공간에 발라버리고 나머지는 관리에서 배제합니다.
if (new_user_ssid != "") {
preferences.putString("user_ssid", new_user_ssid);
preferences.putString("user_pw", new_user_pw);
Serial.println("\n[NVS] 스마트폰으로 전송받은 새로운 와이파이 단일 등록 완료: " + new_user_ssid);
}
}
// --------------------------------------------------------
deviceMAC = WiFi.macAddress();
Serial.println("\n===== BiliMon Nano ESP32 =====");
Serial.println("네트워크 정상 접속 완료!");
Serial.println("현재 연결망: " + WiFi.SSID());
Serial.println("내부 IP: " + WiFi.localIP().toString());
Serial.println("MAC 주소: " + deviceMAC);
Serial.println("현재 버전: " + currentVersion);
MySensor.init();
}
void loop() {
MySensor.readData();
if (millis() - lastPollTime > pollInterval) {
lastPollTime = millis();
Serial.print(outCount);
Serial.print(", ");
Serial.println(outMultiplier);
outCount += 1;
checkManualOTA();
checkAutoOTA();
}
}
// ----------------------------------------------------
// 원격 바이너리 스트림 다운로드 및 플래시 쓰기 공통 함수
// ----------------------------------------------------
void performOTA(String downloadUrl) {
Serial.println("\n[OTA Agent] 플래시 다운로드를 시작합니다 (경로: " + downloadUrl + ")");
WiFiClient client;
HTTPClient http;
http.begin(client, downloadUrl);
http.addHeader("x-api-key", API_KEY);
int httpCode = http.GET();
if (httpCode == HTTP_CODE_OK) {
int contentLength = http.getSize();
Serial.printf("[OTA Agent] 파일 수신 완료. 크기: %d bytes\n", contentLength);
bool canBegin = Update.begin(contentLength);
if (canBegin) {
Serial.println("[OTA Agent] 메모리 확보 성공. 쓰기를 시작합니다...");
size_t written = Update.writeStream(client);
if (written == contentLength) {
Serial.println("[OTA Agent] 스트림 100% 작성을 완료했습니다.");
}
if (Update.end()) {
Serial.println("[OTA Agent] 성공! 새 버전 적용을 위해 2초 뒤 기기를 재부팅합니다.");
Serial.println("[OTA Agent] 서버에 완료 콜백 전송 중...");
HTTPClient httpAck;
httpAck.begin(ota_server_domain + "/api/ota/success?mac=" + deviceMAC);
httpAck.addHeader("x-api-key", API_KEY);
httpAck.GET();
httpAck.end();
// ----- [NVS] 데이터 보존 테스트 백업 -----
myStateVal++;
preferences.putInt("my_state", myStateVal);
Serial.printf("[NVS] 업데이트 누적 성공 횟수(%d) 등 중요 변수를 플래시에 강제 백업합니다.\n", myStateVal);
// ---------------------------------------
delay(2000);
ESP.restart();
} else {
Serial.println("[OTA Agent] 체크섬 에러 등 플래싱 실패. (코드:" + String(Update.getError()) + ")");
}
} else {
Serial.println("[OTA Agent] 저장 공간이 부족합니다.");
}
} else {
Serial.println("[OTA Agent] 서버 통신 불능 상태 (코드:" + String(httpCode) + ")");
}
http.end();
}
void checkManualOTA() {
String url = ota_server_manual + "?mac=" + deviceMAC;
HTTPClient http;
http.begin(url);
http.addHeader("x-api-key", API_KEY);
int httpCode = http.GET();
http.end();
if (httpCode == 200) {
Serial.println("[CLI 상태창] 사용자 지정 펌웨어가 대기중입니다! OTA 진입.");
performOTA(url);
}
}
void checkAutoOTA() {
String url = ota_server_auto + "?mac=" + deviceMAC + "&type=" + deviceType + "&version=" + currentVersion;
HTTPClient http;
http.begin(url);
http.addHeader("x-api-key", API_KEY);
int httpCode = http.GET();
http.end();
if (httpCode == 202) {
Serial.println("[Auto-OTA] 서버가 컴파일을 진행하고 있습니다. 30초 딜레이...");
pollInterval = 30000;
} else if (httpCode == 200) {
Serial.println("[Auto-OTA] 최신 펌웨어가 발견되었습니다. 즉시 다운로드 합니다.");
performOTA(url);
}
}
// ----------------------------------------------------
// 사용자 와이파이 설정 지우기 (데이터 보존 + AP모드 전환용)
// ----------------------------------------------------
void switchToAPMode() {
Serial.println("\n[시스템] 버튼 요청에 따라 와이파이 기록만 삭제하고 AP 모드로 진입합니다.");
WiFiManager wm;
wm.resetSettings(); // 기존 WiFi 캐시 전부 소실
preferences.begin("ota_app", false);
preferences.putString("user_ssid", ""); // 유저가 등록했던 핫스팟 기록만 지움
preferences.putString("user_pw", "");
// 주의: preferences.clear()를 쓰지 않으므로 my_state 등 다른 기기 데이터는 보존됩니다!
Serial.println("[안내] 와이파이 기록 부분 소거 완료! 보드를 재시작합니다.");
delay(1000);
ESP.restart();
}
// ----------------------------------------------------
// 물리적 혹은 소프트웨어적 공장 초기화 통합 함수
// ----------------------------------------------------
void clearAllNVSData() {
Serial.println("\n[시스템] 🚨 펌웨어 코드 지시에 의한 <기기 전체 공장 포맷>을 시작합니다!");
WiFiManager wm;
wm.resetSettings();
preferences.begin("ota_app", false);
preferences.clear(); // 내부 중요 변수들까지 완전 삭제
Serial.println("[NVS포맷] 모든 데이터가 소멸되었습니다. 기기가 초기 생산 상태로 굳어졌습니다.");
if (DO_FORMAT_NVS) {
Serial.println("[안내] 코드로 포맷이 지시된 상태입니다! 코드 상단 DO_FORMAT_NVS를 거꾸로 false로 바꾸고 다시 업로드 하십시오.");
while(1) { delay(1000); }
}
}
반응형
'SWDesk > Firmware' 카테고리의 다른 글
| [Arduino] 가변 주파수를 갖는 GPIO 출력 (0) | 2025.04.20 |
|---|---|
| [Arduino] 클래스(Class) 활용 예제 (0) | 2025.03.16 |
| [Arduino] ESP32 nano, FreeRTOS 실행 시 메모리 충돌 방지 대책 (2) | 2025.03.14 |
| [Arduino] ESP32 nano에서 WiFi 전송과 GPIO 컨트롤을 동시에 하는 방법 (0) | 2025.03.13 |
| [Arduino] JSON Transfer through RS-485 (0) | 2025.03.12 |
반응형
250x250
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- image
- 심심풀이
- 배프
- Innovations
- 전류
- 치매
- BiliChild
- 둎
- DYOV
- bilient
- 허들
- Video
- 심심풀이치매방지기
- 절연형
- Hurdles
- 오블완
- Innovations&Hurdles
- 전압
- 빌리칠드
- 티스토리챌린지
- 혁신
- ServantClock
- 혁신과허들
- 치매방지
- 아두이노
- Decorator
- arduino
- 빌리언트
- Innovation&Hurdles
- BSC
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
글 보관함

