개요 및 구성
https://fortex66.tistory.com/13
이전 글에서 파이썬에서 Modbus TCP/IP 프로토콜을 사용한 PLC와 PC의 통신 프로그램을 만들었다.
파이썬을 사용한 GUI 프로그램으로 만드는 것보다 누구나 쉽게 접근할 수 있는 Web으로 만드는 것이 좋겠다는 생각이 들어서 이번에는 Web 버전으로 만들었다.
아래의 사진처럼 프로그램을 만들었고 실제로 배포까지 완료한 상태이다. (링크는 글 마지막 부분에)
자세한 정보들은 아래의 결과물 부분에 기재하였다.
임베디드 | LS산전 - PLC, OMROM - E5CC 온도계, 온도센서, 백열등, 전자회로 | ||
서버 | Javascript, Node.js, Express | ||
클라이언트 | Javascript, React | ||
데이터베이스 | MySQL | ||
형상관리 | Github, Git | ||
배포 | Cafe24 node.js 웹 호스팅 |
주요 기능
1. 실시간 온도값 모니터링
현재 온도가 설정 온도를 잘 따라가고 있는지 실시간으로 모니터링할 수 있다.
2. 온도계 제어
사용자가 온도계의 ON/OFF와 온도를 직접 설정할 수 있다.
3. 데이터 저장
10초마다 현재 온도값, 설정 온도값, 온도계 ON/OFF상태, 시간을 자동으로 데이터베이스에 저장한다.
4. 데이터 불러오기
데이터베이스에 저장된 데이터를 날짜와 시간으로 조회하여 그래프로 보여준다.
5. 데이터 파일로 저장 및 그래프 캡쳐
조회한 데이터를 엑셀파일로 저장하거나 그래프를 사진으로 캡처할 수 있다.
6. 사용자 커스텀 기능
프로그램의 자유도를 높이기 위해 사용자가 데이터 모니터링 주기, 그래프 축 설정, 그래프 색상 등을 지정할 수 있다.
7. 방문자 수 보기
클라이언트가 실행되면서 로컬스토리지를 사용하여 오늘 방문여부를 기록하고 확인하여 데이터 베이스에 저장하고 저장된 데이터 베이스로부터 전체 방문자 수와 오늘 방문자 수를 가져와서 클라이언트에서 볼 수 있다.
개발 과정
1. 임베디드 구성
오므론 E5CC 온도계와 온도센서, 백열등, LS PLC를 사용하여 구성하였다.
2. 파이썬 통신 프로그램 구성
https://fortex66.tistory.com/13
기존에 작성하였던 Modbus TCP/IP 프로토콜을 사용한 PLC와의 통신 프로그램이다.
3. 로컬 환경에서 Web 프로그램 구성
파이썬과 다르게 웹은 Backend와 Frontend로 나누어서 작업을 하기 때문에 기존에 PLC와 통신하던 프로그램과는 다르게 구성하였다.
각각의 파일구조는 사진처럼 구성을 하였고 Frontend는 데이터의 가공과 API요청에 집중하였고 Backend는 PLC와 Modbus TCP 통신을 하는 부분과 클라이언트와의 REST API부분에 집중하여 구성하였다.
const Modbus = require('modbus-serial');
const client = new Modbus();
client.setTimeout(TIMEOUT); // 모드버스 응답에 대해서 타임아웃 추가 -> 무한대기 방지
// Modbus 클라이언트 설정 및 연결
async function connectClient() {
try {
await client.connectTCP(HOST, { port: PORT });
client.setID(1);
console.log("Modbus client connected");
} catch (error) {
console.error("Failed to connect Modbus client:", error);
}
}
connectClient();
Node.js에서는 modbus-serial을 설치하면 간단하게 통신을 할 수 있다.
우선 클라이언트 설정과 연결을 하고 PLC와 연결되어 있는 공유기의 IP주소와 포트로 연결을 시도한다.
연결이 되면 Modbus client connected라는 콘솔을 출력된다.
// Modbus 서버(PLC)와의 연결 상태를 검사하는 함수
async function checkConnection() {
try {
// 간단한 요청으로 연결 상태 확인
await client.readCoils(0, 1);
} catch (error) {
console.error("Connection check failed, trying to reconnect:", error);
await connectClient();
}
}
// 연결 상태를 확인하고 주어진 작업을 재시도 로직과 함께 실행한다. 작업 실행 중 예외가 발생하면, 지정된 횟수(RETRY_LIMIT)만큼 작업을 재시도
async function modbusOperation(operationFunc, ...args) {
await checkConnection();
for (let attempt = 1; attempt <= RETRY_LIMIT; attempt++) {
try {
return await operationFunc(...args);
} catch (error) {
console.error(`Attempt ${attempt}: ${error.message}`);
// 네트워크 에러 처리
if (error.name == 'PortNotOpenError') {
console.log("Network error detected, attempting to reconnect...");
//await reconnectWithBackoff(attempt);
}
// 응답 없음 에러 처리
else if (error.name == 'NoResponseError') {
console.log("No response from the device, checking connection...");
await checkConnection();
}
// 타임아웃 에러 처리
else if (error.name == 'TransactionTimedOutError'){
console.log("Timeout Error, server not responding in 5 seconds ")
}
// 다른 유형의 에러 처리
else {
console.log("An unexpected error occurred, retrying...");
}
if (attempt === RETRY_LIMIT) throw new Error("Max retry attempts reached.");
}
}
}
// D1000 레지스터 읽기 (현재 온도)
async function readCurrentTemperature() {
return await modbusOperation(async () => {
const { data } = await client.readInputRegisters(1000, 1);
return data[0] / 10; // 소수점 처리
});
}
// D2000 레지스터 읽기 (설정 온도)
async function readSetTemperature() {
return await modbusOperation(async () => {
const { data } = await client.readInputRegisters(2000, 1);
return data[0] / 10; // 소수점 처리
});
}
// D2000 레지스터 쓰기 (설정 온도)
async function writeSetTemperature(value) {
return await modbusOperation(async () => {
await client.writeRegister(2000,value); // 소수점 처리를 반영하여 저장
});
}
// D1105 레지스터 읽기 (온도계 상태) on -> 512 / off -> 768
async function readThermostatStatus() {
return await modbusOperation(async () => {
const { data } = await client.readInputRegisters(1105, 1);
return data[0]; // 상태 코드 반환
});
}
// D1200 레지스터 쓰기 (온도계 제어)
async function writeThermostatControl(value) {
return await modbusOperation(async () => {
await client.writeRegister(1200, value); // 온도계 제어 값 쓰기
});
}
처음에는 단순하게 코드의 마지막에 있는 레지스터 읽기 쓰기 코드만 만들었는데 생각보다 통신하면서 에러가 많이 발생하여 무한 대기 상태에 빠지는 경우가 빈번하게 발생하였다.
그래서 Timeout을 사용하여 무한대기 상태에 빠지지 않게 하였고 레지스터 함수들을 실행하기 전에 modbusOperation에서 연결 상태를 확인하고 해당하는 레지스터 함수를 실행하고 만약 작업 실행 중 예외가 발생하면, 지정된 횟수(RETRY_LIMIT)만큼 작업을 재시도한다.
레지스터 함수들은 매우 간단하게 구성을 할 수 있다.
이 표를 참고해서 만들었으며 이 표는 아래의 공식문서에서 찾아볼 수 있고 코드 예시들도 볼 수 있다.
https://www.npmjs.com/package/modbus-serial
4. 공유기 포트 포워딩을 이용한 외부 접속 허용
공유기 설정에 들어가서 포트 포워딩 설정을 하고 모드버스 포트를 설정하게 되면 외부에서 바로 PLC modbus통신이 가능하다.
5. 로컬 환경의 프로그램을 외부에서도 실행할 수 있게 수정
이 과정에서는 단순히 192.168.0.xxx였던 IP 주소를 공유기의 IP주소로 변경하였다.
6. 카페24 Node.js 웹 호스팅을 서비스에 맞게 프로그램 수정 및 데이터베이스 구성
Node.js 에서 MySQL 작업을 수월하게 하기 위해서 Sequelize를 사용하여 작업을 하였다.
const { Sequelize } = require('sequelize');
const dotenv = require('dotenv');
dotenv.config();
// Sequelize 인스턴스 생성
const sequelize = new Sequelize(
process.env.MYSQL_DATABASE,
process.env.MYSQL_USERNAME,
process.env.MYSQL_PASSWORD, {
host: process.env.MYSQL_HOST,
dialect: 'mysql',
timezone: '+09:00', // 서울 시간대
port: process.env.MYSQL_PORT,
logging: console.log,
}
);
// 연결 테스트
sequelize.authenticate()
.then(() => {
console.log('Connection has been established successfully.');
})
.catch((error) => {
console.error('Unable to connect to the database:', error);
});
module.exports = sequelize;
데이터베이스를 환경변수에 있는 정보를 바탕으로 생성했다.
const {DataTypes} = require('sequelize');
const sequelize = require('../database/database');
const temperaturerecords = sequelize.define('temperaturerecords', {
temperature: DataTypes.FLOAT,
settingtemp: DataTypes.FLOAT,
timestamp: DataTypes.DATE,
thermostatStatus: DataTypes.BOOLEAN,
}, {
timestamps: false,
tableName: 'temperaturerecords',
});
module.exports = temperaturerecords;
현재온도와 설정온도, 시간, 온도계 상태를 저장하는 테이블을 만들었다.
const { DataTypes } = require('sequelize');
const sequelize = require('../database/database');
const visitorLogs = sequelize.define('visitorLogs', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
visitor_id: {
type: DataTypes.STRING(255),
allowNull: false
},
visit_timestamp: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
}
}, {
timestamps: false,
tableName: 'visitor_logs'
});
module.exports = visitorLogs;
방문자 수를 기록하기 위한 테이블을 만들었다.
visitor_id에 클라이언트에서 생성한 UUID를 넣어서 클라이언트별로 고유 식별하기 위한 정보를 저장한다.
7. 테스트 버전 배포
const express = require('express');
const bodyParser = require('body-parser');
const modbusRoutes = require('./routes/modbusRoutes');
const app = express();
const path = require('path');
const modbusClient = require('./utils/modbusClient');
const sequelize = require('./database/database');
const visitRoutes = require('./routes/visitRoutes');
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, "/build")));
app.use('/api/visit', visitRoutes);
app.use('/api/modbus', modbusRoutes);
app.get("/", function (req, res) {
res.sendFile(path.join(__dirname, "/build/index.html"));
});
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "/build/index.html"));
});
// 데이터베이스 동기화 및 서버 시작
sequelize.authenticate()
.then(() => {
console.log('Connection has been established successfully.');
// 모델과 데이터베이스 동기화
return sequelize.sync({ force: false });
})
.then(() => {
console.log('Database & tables created!');
// 주기적으로 데이터 저장하는 함수 시작
modbusClient.startPeriodicSave();
// 서버 시작
app.listen(process.env.PORT || 8001, () => {
console.log(`Server is running on port ${process.env.PORT || 8001}`);
});
})
.catch(err => {
console.error('Unable to connect to the database:', err);
});
클라이언트를 빌드하여 Backend파일에 넣어서 경로 설정과 환경변수 설정들을 바꿔주고 Git으로 카페24에서 생성한 저장소 주소로 밀어 넣으면 배포가 끝이다.
최종 결과물
메인페이지
처음 사이트에 들어가면 나오게 되는 부분이다.
상단에 다크모드 기능을 넣어 어둡게 변경할 수 있고 설정 온도, 현재 온도, 온도계 상태를 텍스트로 볼 수 있으며 현재 온도와 설정 온도는 실시간 그래프로 설정한 주기마다 데이터를 모니터링할 수 있다.
사이드에는 내비게이션 바와 방문자 수를 볼 수 있다.
스크롤을 아래로 내리면 온도를 제어할 수 있다.
History 페이지
날짜와 조회 주기를 입력하고 조회를 하면 해당 날짜의 데이터를 MySQL로부터 가져와서 그래프로 표시한다.
그리고 우측에 CSV(엑셀) 파일로 저장이 가능하며 그래프 캡처 기능도 있다.
아래는 데이터 분석 부분이며 조회된 데이터의 최대, 최소, 평균값을 알 수 있다.
Setting 페이지
사용자가 커스텀할 수 있는 기능들을 넣어두었으며 메인페이지의 데이터의 주기설정과 그래프 최대, 최솟값 설정, 그래프 색상을 직접 설정하여 자유도를 높였다.
보완할 점
1. 유동 IP 문제
공유기의 IP주소는 고정 IP가 아니라 유동 IP라서 고정 IP를 사용하던지 DDNS를 사용하여야 한다.
가장 확실한 방법은 고정 IP를 사용하는 것이지만 비용 때문에 고려해 보아야 한다.
만약 실제 서비스를 제공하게 되면 고정 IP를 사용하면 되지만 지금은 테스트 중이라 아직 도입하기는 이르다.
두 번째는 DDNS를 사용하는 것이다.
우리가 웹사이트의 IP주소를 외우고 다니는 게 아니라 네이버, 구글 등의 알파벳으로 된 도메인 이름만 알면 접속이 가능하다. 따라서 IP주소가 변경되더라도 DDNS로 설정한 도메인은 변하지 않기 때문에 쉽게 접속할 수 있다.
물론 공유기의 전원을 끄지 않으면 IP주소는 변하지 않지만 만약 꺼지고 IP주소가 바뀐다면 바뀐 IP주소를 다시 프로그램에서 수정하여 서버에 올려야 하므로 번거롭다.
2. 온도 설정 모드 추가
지금은 온도 설정을 하게 되면 설정온도로 자동으로 PID 제어가 되어 올라가는데 계단식으로 천천히 온도를 올려야 하는 경우나 주기적으로 온도가 자동으로 운전되는 모드가 필요할 수 있다.
그래서 온도 설정 모드를 추가하여 PLC에 프로그래밍되어 있는 대로 온도를 제어할지 아니면 웹으로 제어할지 선택할 수 있는 기능을 추가할 것이다.
3. 사용자 참여의 한계
지금은 하나의 온도만 모니터링하고 기록하지만 사용자가 온도계를 추가로 증설하게 되면 프로그램을 다시 만들어야 하는 문제가 발생한다.
게시판의 글쓰기처럼 사용자가 직접 추가, 삭제할 수 있게 만들어야 할 것 같다. IP주소와 모드버스 읽기, 쓰기 등의 커스텀 과정들을 거쳐서 자동으로 추가되게 할 필요가 있다.
4. 프로그램 로그인 기능
현재 로그인 기능이 없어서 아무나 모니터링, 제어, 기록 열람이 가능 하지만 추후에는 로그인 기능을 만들어서 제어, 기록 열람은 로그인 한 유저만 볼 수 있게 변경할 것이다.
5. 실시간 동접자 수
동시에 여러 사람이 제어하거나 모니터링하는 경우가 발생할 수 있으므로 동접자 수를 표시하여 오해가 발생하지 않게 하면 좋을 것 같다.
느낀 점
1. 실시간으로 통신을 주기적으로 하면서 클라이언트에서 데이터를 보여줘야 하기 때문에 동기/비동기 처리에서 생각보다 문제가 많이 발생하였고 특히 백엔드에서 PLC와의 통신의 안정성 때문에 시간을 많이 할애하였다.
Javascript는 싱글스레드지만 런타임 환경(Node.js)에서 비동기 처리를 통해 멀티스레드처럼 동작할 수 있다. 하지만 다음 버전을 만들 때는 서버를 Javascript를 사용하지 않고 Java를 사용하여 멀티스레드로 프로그램을 구성해 보고 싶다.
2. PLC의 추가적인 기능들에 대해 조금 더 알아볼 필요가 있고 잘 활용하여 서버의 부하를 줄이면 좋을 것 같다.
3. 통신이 많기 때문에 C를 사용하여 프로그램을 만들면 좋을 것 같고 특히 서버의 프로그램을 Javascript가 아닌 다른 언어로 구성을 해 보고 싶다.
프로그램 공유 및 사이트 바로가기
github
https://github.com/fortex66/ForTex-Viewer
프로그램에 대한 자세한 설명과 코드를 보고 싶으시면 위의 주소로 들어와서 보시면 됩니다!
다운로드해서 직접 사용해 보실 수 있지만 임베디드 구성을 하셔야 하므로 실제 프로그램을 동작시키려면 환경 설정을 해야 합니다.
오픈 소스라서 누구나 코드를 볼 수 있고 다운로드하여 사용이 가능합니다.
도움이 되셨다면 위의 사이트에 접속하셔서 사진의 빨간 동그라미 부분의 Star를 눌러주시면 큰 힘이 됩니다!!
아래의 주소로 들어오시면 배포된 테스트 버전의 프로그램을 보실 수 있습니다.
배포 사이트
http://fortex66.cafe24app.com/
React App
fortex66.cafe24app.com