아두이노 웹 기반 XY 플로터 - 스테퍼 모터 컨트롤러
부품 및 소모품
| 부품 |
수량 |
| 아두이노 우노 |
1 |
| PHPoC 와이파이 실드 2 (아두이노용) |
1 |
| PHPoC 스테퍼 모터 컨트롤러 |
2 |
| 메이크블록 XY 플로터 |
1 |
프로젝트 소개
시연
아두이노를 처음 접한다면 다음 자료로 시작해 보세요:
작동 원리
웹페이지의 그리기 영역을 손가락으로 터치하면 해당 XY 좌표가 아두이노로 전송됩니다. 좌표를 스케일링한 후 아두이노는 두 개의 스테퍼 모터를 제어하여 펜을 해당 위치로 이동시킵니다. 이동 중 아두이노는 펜의 궤적을 웹 앱으로 지속적으로 전송하며, 웹 앱은 캔버스에 궤적을 그려줍니다.
소스 코드
소스 코드는 아두이노 코드와 웹 사용자 인터페이스 코드 두 부분으로 구성됩니다.
아두이노 코드
무한 루프에서 다음 작업을 수행합니다:
- 웹페이지에서 명령 수신 및 명령에 따른 작업 수행
CMD_MOVE: 두 스테퍼 모터 제어로 펜을 특정 위치로 이동
CMD_PEN_UP: 서보 모터 각도 변경으로 펜 들어올리기
CMD_PEN_DOWN: 서보 모터 각도 변경으로 펜 내리기
- 펜의 현재 위치를 지속적으로 읽어 웹페이지로 전송
웹 사용자 인터페이스
- 사용자 인터페이스 제공
- 사용자 이벤트 처리 및 좌표와 함께 아두이노로 명령 전송
- 아두이노에서 궤적 수신 및 웹페이지에 그리기
remote_draw.php 파일에 웹 사용자 인터페이스 코드가 포함되어 있으며, PHPoC 와이파이 실드에 저장해야 합니다. 업로드 방법은 다음과 같습니다:
- 아래 코드를 복사해
remote_draw.php 파일로 저장
- PHPoC 디버거 설치
- 마이크로 USB 케이블로 PHPoC를 PHPoC 와이파이 실드에 연결 (아두이노에 전원 공급 필요)
- PHPoC 디버거 지침에 따라
remote_draw.php 파일을 PHPoC 와이파이 실드에 업로드
구현 방법
- PHPoC 실드 또는 PHPoC 와이파이 실드의 네트워크 정보 설정
- PHPoC 라이브러리 설치
- PHPoC 확장 라이브러리 설치
- 아두이노에 코드 컴파일 및 업로드
- 웹 사용자 인터페이스를 PHPoC 와이파이 실드에 업로드
- 시리얼 모니터에서 PHPoC 실드의 IP 주소 복사
- 웹 브라우저로 웹 사용자 인터페이스 접속:
https://실드_IP_주소/remote_draw.php
- 웹에서 그리기 시작
전자 부품 구매는 utsource.net에서 가능합니다.
추천 아두이노 스타터 키트
초보자를 위한 최고의 아두이노 키트 보기
함수 레퍼런스
Serial.begin()
Serial.println()
delay()
millis()
for 루프
while 루프
if else
loop()
setup()
String.toInt()
String.substring()
String.indexOf()
String.remove()
String.equals()
코드
아두이노 코드
#include <Phpoc.h>
#include <PhpocExpansion.h>
#include <Servo.h>
#define MAX_X 55550 // 단위: 스텝
#define MAX_Y 68780 // 단위: 스텝
#define TOUCH_OFFSET 5000 // 단위: 스텝
#define PEN_STATE_UP 0
#define PEN_STATE_DOWN 1
#define CMD_PEN_UP 0
#define CMD_PEN_DOWN 1
#define CMD_MOVE 2
#define STEP_MODE 32
#define SPEED_X_COEF ((long)40 * STEP_MODE)
#define SPEED_Y_COEF ((long)40 * STEP_MODE)
#define SPEED_X_MAX ((long)1500 * STEP_MODE)
#define SPEED_Y_MAX ((long)1500 * STEP_MODE)
#define ACCEL_X_MAX ((long)6000 * STEP_MODE)
#define ACCEL_Y_MAX ((long)6000 * STEP_MODE)
#define STEP_STATE_STOP 0
#define STEP_STATE_LOCK 1
#define RESOLUTION 500
#define MIN_UPDATE_INTERVAL 100 // 밀리초 단위
PhpocServer server(80);
ExpansionStepper stepX(14);
ExpansionStepper stepY(13);
Servo servo;
long preX = 0;
long preY = 0;
byte penState = PEN_STATE_UP;
bool isUnlockedX = false;
bool isUnlockedY = false;
int forwardDirX = -1; // 모터 X 정방향 시 XY 플로터 방향, 설치에 따라 결정
int forwardDirY = +1; // 모터 Y 정방향 시 XY 플로터 방향, 설치에 따라 결정
unsigned long lastUpdateMillis;
void penUp() {
servo.write(110);
penState = PEN_STATE_UP;
}
void penDown() {
servo.write(180);
penState = PEN_STATE_DOWN;
}
void xyWait() {
while(stepX.getState() > 1 || stepY.getState() > 1);
}
void xyInit() {
penUp();
stepX.setMode(STEP_MODE);
stepX.setVrefStop(4);
stepX.setVrefDrive(15);
stepX.setResonance(120, 250);
stepX.setSpeed(20000);
stepX.setAccel(50000);
stepY.setMode(STEP_MODE);
stepY.setVrefStop(4);
stepY.setVrefDrive(15);
stepY.setResonance(120, 250);
stepY.setSpeed(20000);
stepY.setAccel(50000);
// 펜을 (0, 0)으로 이동
stepX.stepGotoSW(0, -forwardDirX);
stepY.stepGotoSW(0, -forwardDirY);
xyWait();
stepX.setPosition(0);
stepY.setPosition(0);
// 최초 실행 시 아래 블록 주석 해제하고 IDE 콘솔 값에 맞춰 line 6, 7 및 index.php line 33 수정
/*
// 최대 스텝 확인
stepX.stepGotoSW(1, forwardDirX);
stepY.stepGotoSW(1, forwardDirY);
xyWait();
Serial.print(F("MAX_X:"));
Serial.println(stepX.getPosition() * forwardDirX);
Serial.print(F("MAX_Y:"));
Serial.println(stepY.getPosition() * forwardDirY);
*/
xyGoto(TOUCH_OFFSET, TOUCH_OFFSET);
xyWait();
stepX.setEioMode(0, EIO_MODE_LOCK);
stepX.setEioMode(1, EIO_MODE_LOCK);
stepY.setEioMode(0, EIO_MODE_LOCK);
stepY.setEioMode(1, EIO_MODE_LOCK);
}
void xyGoto(long x, long y) {
if(x < TOUCH_OFFSET) x = TOUCH_OFFSET;
else if(x > (MAX_X - TOUCH_OFFSET)) x = MAX_X - TOUCH_OFFSET;
if(y < TOUCH_OFFSET) y = TOUCH_OFFSET;
else if(y > (MAX_Y - TOUCH_OFFSET)) y = MAX_Y - TOUCH_OFFSET;
x *= forwardDirX;
y *= forwardDirY;
int32_t deltaX = x - stepX.getPosition();
int32_t deltaY = y - stepY.getPosition();
deltaX = abs(deltaX);
deltaY = abs(deltaY);
long speedX = SPEED_X_COEF * deltaX;
long speedY = SPEED_Y_COEF * deltaY;
if(speedX > speedY) {
if(speedX > SPEED_X_MAX) speedX = SPEED_X_MAX;
double ratio = deltaY / (double)deltaX;
speedY = (long)(ratio * speedX);
} else {
if(speedY > SPEED_Y_MAX) speedY = SPEED_Y_MAX;
double ratio = deltaX / (double)deltaY;
speedX = (long)(ratio * speedY);
}
long accelX, accelY;
if(speedX < speedY) {
accelY = ACCEL_Y_MAX;
double ratio = accelY / (double)speedY;
accelX = (long)(ratio * speedX);
} else {
accelX = ACCEL_X_MAX;
double ratio = accelX / (double)speedX;
accelY = (long)(ratio * speedY);
}
if(deltaX != 0) stepX.command(F("goto %ld %lu %lu"), x, speedX, accelX);
if(deltaY != 0) stepY.command(F("goto %ld %lu %lu"), y, speedY, accelY);
}
void xyCheckUpdateToWeb() {
bool isUpdate = false;
unsigned long curMillis = millis();
if((curMillis - lastUpdateMillis) > MIN_UPDATE_INTERVAL) isUpdate = true;
long curX = stepX.getPosition();
long curY = stepY.getPosition();
long deltaX = curX - preX;
long deltaY = curY - preY;
long dist = sqrt(pow(deltaX, 2) + pow(deltaY, 2));
if(dist > RESOLUTION) isUpdate = true;
if(isUpdate == false || dist == 0) return false;
lastUpdateMillis = curMillis;
preX = curX;
preY = curY;
sendPositionToWeb();
}
void sendPositionToWeb() {
char wbuf[20];
long x = stepX.getPosition() * forwardDirX;
long y = stepY.getPosition() * forwardDirY;
String data = String(F("[")) + x + String(F(",")) + y + String(F(",")) + penState + String(F("]\n"));
data.toCharArray(wbuf, data.length() + 1);
server.write(wbuf, data.length());
}
void setup() {
Serial.begin(9600);
while(!Serial);
Phpoc.begin(PF_LOG_SPI | PF_LOG_NET);
server.beginWebSocket("xy_plotter");
Serial.print("WebSocket server address: ");
Serial.println(Phpoc.localIP());
Expansion.begin(460800);
servo.attach(8);
/** 최초 실행 시 주의사항:
* - xyInit()의 마지막 블록 주석 해제
* - 아두이노 코드 실행
* - IDE 콘솔 값에 맞춰 이 파일의 line 6, 7과 index.php의 line 33 수정
**/
xyInit();
lastUpdateMillis = millis();
}
void loop() {
PhpocClient client = server.available();
if(client) {
String data = client.readLine();
if(data) {
byte separatorPos1 = data.indexOf(':');
byte separatorPos2 = data.lastIndexOf(':');
byte cmd = data.substring(0, separatorPos1).toInt();
long x = data.substring(separatorPos1 + 1, separatorPos2).toInt();
long y = data.substring(separatorPos2 + 1).toInt();
switch(cmd) {
case CMD_PEN_DOWN:
xyGoto(x, y);
while(stepX.getState() > 1 || stepY.getState() > 1) xyCheckUpdateToWeb();
penDown();
break;
case CMD_PEN_UP:
while(stepX.getState() > 1 || stepY.getState() > 1) xyCheckUpdateToWeb();
penUp();
break;
case CMD_MOVE:
xyGoto(x, y);
break;
}
}
}
xyCheckUpdateToWeb();
}
웹 사용자 인터페이스 (PHP)
<!DOCTYPE html>
<html>
<head>
<title>Arduino - PHPoC Shield - XY Plotter</title>
<meta name="viewport" content="width=device-width, initial-scale=0.7">
<style>
body { text-align: center; background-color: #33C7F2; }
#canvas { margin: 0 auto; position: relative; background-color: #FFFFFF; }
canvas { position: absolute; left: 0; top: 0; }
#layer1 { z-index: 2; }
#layer2 { z-index: 1; }
#layer3 { z-index: 0; }
</style>
<script>
var PEN_STATE_UP = 0, PEN_STATE_DOWN = 1;
var CMD_PEN_UP = 0, CMD_PEN_DOWN = 1, CMD_MOVE = 2;
var RESOLUTION = 1000, MIN_UPDATE_INTERVAL = 100;
var MAX_X = 55550, MAX_Y = 68780;
var CANVAS_WIDTH = 0, CANVAS_HEIGHT = 0;
var ws = null, canvas = null;
var layer1 = null, layer2 = null, layer3 = null;
var ctx1 = null, ctx2 = null, ctx3 = null;
var touchState = 0, touchStepX = 0, touchStepY = 0;
var touchPixelX = 0, touchPixelY = 0;
var plotterX = 0, plotterY = 0, plotterPenState = PEN_STATE_UP;
var lastUpdateMillis, buffer = "";
function init() {
canvas = document.getElementById("canvas");
layer1 = document.getElementById("layer1");
layer2 = document.getElementById("layer2");
layer3 = document.getElementById("layer3");
ctx1 = layer1.getContext("2d");
ctx2 = layer2.getContext("2d");
ctx3 = layer3.getContext("2d");
layer1.addEventListener("touchstart", mouse_down);
layer1.addEventListener("touchend", mouse_up);
layer1.addEventListener("touchmove", mouse_move);
layer1.addEventListener("mousedown", mouse_down);
layer1.addEventListener("mouseup", mouse_up);
layer1.addEventListener("mousemove", mouse_move);
canvasResize();
var d = new Date();
lastUpdateMillis = d.getTime();
}
function ws_onmessage(e_msg) {
buffer += e_msg.data;
var pos = buffer.indexOf('\n');
if(pos == -1) return;
var data = buffer.substring(0, pos);
buffer = buffer.substring(pos + 1);
var arr = JSON.parse(data);
plotterX = arr[0];
plotterY = arr[1];
var newPlotterPenState = arr[2];
plotterX = Math.round(plotterX * CANVAS_WIDTH / MAX_X);
plotterY = -Math.round(plotterY * CANVAS_HEIGHT / MAX_Y);
ctx1.clearRect(0, 0, CANVAS_WIDTH, -CANVAS_HEIGHT);
ctx1.beginPath();
ctx1.arc(plotterX, plotterY, 7, 0, 2*Math.PI);
ctx1.fill();
if(plotterPenState == PEN_STATE_UP && newPlotterPenState == PEN_STATE_DOWN)
ctx2.beginPath();
if(newPlotterPenState == PEN_STATE_DOWN) {
ctx2.lineTo(plotterX, plotterY);
ctx2.stroke();
}
plotterPenState = newPlotterPenState;
}
function ws_onopen() {
document.getElementById("ws_state").innerHTML = "OPEN";
document.getElementById("wc_conn").innerHTML = "Disconnect";
}
function ws_onclose() {
document.getElementById("ws_state").innerHTML = "CLOSED";
document.getElementById("wc_conn").innerHTML = "Connect";
ws.onopen = ws.onclose = ws.onmessage = null;
ws = null;
}
function wc_onclick() {
if(ws == null) {
ws = new WebSocket("ws:///xy_plotter", "text.phpoc");
document.getElementById("ws_state").innerHTML = "CONNECTING";
ws.onopen = ws_onopen;
ws.onclose = ws_onclose;
ws.onmessage = ws_onmessage;
} else ws.close();
}
function event_handler(event, type) {
if(event.targetTouches) {
if(event.targetTouches.length > 1) return false;
touchPixelX = event.targetTouches[0].pageX - canvas.offsetLeft;
touchPixelY = event.targetTouches[0].pageY - canvas.offsetTop - CANVAS_HEIGHT;
} else {
touchPixelX = event.offsetX;
touchPixelY = event.offsetY - CANVAS_HEIGHT;
}
var newTouchStepX = Math.round(touchPixelX / CANVAS_WIDTH * MAX_X);
var newTouchStepY = Math.round((-touchPixelY) / CANVAS_HEIGHT * MAX_Y);
if(type == "MOVE") {
var isUpdate = false;
var d = new Date();
var curMillis = d.getTime();
if((curMillis - lastUpdateMillis) > MIN_UPDATE_INTERVAL) isUpdate = true;
var deltaX = newTouchStepX - touchStepX;
var deltaY = newTouchStepY - touchStepY;
var dist = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
if(dist > RESOLUTION) isUpdate = true;
if(isUpdate == false) return false;
lastUpdateMillis = curMillis;
}
touchStepX = newTouchStepX;
touchStepY = newTouchStepY;
drawXYline();
return true;
}
function mouse_down() {
event.preventDefault();
event_handler(event, "DOWN");
sendToPlotter(CMD_PEN_DOWN, touchStepX, touchStepY);
touchState = 1;
}
function mouse_up() {
event.preventDefault();
touchState = 0;
sendToPlotter(CMD_PEN_UP, touchStepX, touchStepY);
}
function mouse_move() {
event.preventDefault();
if(touchState == 1 && event_handler(event, "MOVE"))
sendToPlotter(CMD_MOVE, touchStepX, touchStepY);
}
function sendToPlotter(cmd, x, y) {
if(ws != null && ws.readyState == 1)
ws.send(cmd + ":" + touchStepX + ":" + touchStepY + "\r\n");
}
function canvasClear() {
ctx2.clearRect(0, 0, CANVAS_WIDTH, -CANVAS_HEIGHT);
}
function canvasResize() {
var width = Math.round(window.innerWidth * 0.95);
var height = Math.round(window.innerHeight * 0.95) - 100;
var temp_height = Math.round(width * MAX_Y / MAX_X);
if(temp_height <= height) {
CANVAS_WIDTH = width;
CANVAS_HEIGHT = temp_height;
} else {
CANVAS_WIDTH = height * MAX_X / MAX_Y;
CANVAS_HEIGHT = height;
}
canvas.style.width = CANVAS_WIDTH + "px";
canvas.style.height = CANVAS_HEIGHT + "px";
layer1.width = layer2.width = layer3.width = CANVAS_WIDTH;
layer1.height = layer2.height = layer3.height = CANVAS_HEIGHT;
ctx1.translate(0, CANVAS_HEIGHT);
ctx2.translate(0, CANVAS_HEIGHT);
ctx3.translate(0, CANVAS_HEIGHT);
ctx1.fillStyle = "#00979d";
ctx2.lineWidth = 3; ctx2.strokeStyle = "black";
ctx3.strokeStyle = "00FFFF";
}
function drawXYline() {
ctx3.clearRect(0, 0, CANVAS_WIDTH, -CANVAS_HEIGHT);
ctx3.beginPath();
ctx3.moveTo(0, touchPixelY); ctx3.lineTo(CANVAS_WIDTH, touchPixelY);
ctx3.moveTo(touchPixelX, 0); ctx3.lineTo(touchPixelX, -CANVAS_HEIGHT);
ctx3.stroke();
}
window.onload = init;
</script>
</head>
<body onresize="canvasResize()">
<br>
<div id="canvas">
<canvas id="layer1"></canvas>
<canvas id="layer2"></canvas>
<canvas id="layer3"></canvas>
</div>
<p>WebSocket: <span id="ws_state">null</span></p>
<button id="wc_conn" type="button" onclick="wc_onclick()">Connect</button>
<button type="button" onclick="canvasClear()">Clear</button>
</body>
</html>