Compare commits

63 Commits

Author SHA1 Message Date
13557d32a6 Merge branch 'develop_mh' 2025-05-08 13:43:11 +09:00
32842d4b6e 스케줄러 select height 오류 보정 2025-05-08 13:42:55 +09:00
c6dd64137b Merge remote-tracking branch 'origin/main' 2025-05-08 13:37:52 +09:00
703a279ffa 스케줄러 select height 오류 보정 중 2025-05-08 13:37:22 +09:00
27bf89fa3e sms 주석 제거 2025-05-08 10:58:12 +09:00
0d388b8c42 버튼 텍스트 사이즈 조정 2025-04-29 15:59:33 +09:00
ba25fada8e 스케줄러 view 컬럼 width 정렬 수정 2025-04-29 15:49:10 +09:00
ac06cd281f 스케줄러 view 컬럼 width 일괄 fix 2025-04-29 15:41:46 +09:00
a98aab4f45 기본 폰트 pretendard 적용 2025-04-29 03:13:30 +09:00
36bea152fd 현황 폰트 적용 오류 수정 2025-04-29 03:03:41 +09:00
90f89e08b8 Lint 적용 2025-04-29 02:59:13 +09:00
7a99cd1dda 사이드바 on/off 추가, 숫자 폰트 샘플 적용 2025-04-29 02:58:36 +09:00
9badb4dcd1 스케줄러 초기화면 샘플 2025-04-29 01:45:33 +09:00
05f610bd86 스케줄러 edit view 반응형 디테일 수정 3 2025-04-28 21:19:50 +09:00
91aefdba24 스케줄러 edit view 반응형 디테일 수정 2 2025-04-28 15:35:21 +09:00
fc61bc87f6 스케줄러 edit view 반응형 디테일 수정 2025-04-28 15:12:55 +09:00
02932e8b79 스케줄러 edit view 컬럼별 반응형 width 수정 2025-04-28 13:28:12 +09:00
f7cedfd799 헤더 모바일용 메뉴 버튼 추가, 스케줄러 edit view 스타일 수정 2025-04-28 13:02:01 +09:00
07c5d0fa2a 스케줄러 edit 스타일 리스트로 수정 및 토글 버튼 추가 2025-04-28 12:02:08 +09:00
69c98034d6 스케줄러 view 스타일 리스트로 수정 2025-04-28 11:26:34 +09:00
2f237acaec Modal 스타일 수정 2025-04-25 14:00:52 +09:00
55715a20eb scroll y area height fix 해제 2025-04-25 13:53:20 +09:00
29841e2d15 스케쥴이 nil 일때 처리 2025-04-25 13:50:16 +09:00
faaf8677e3 Lint 적용 2025-04-25 13:30:15 +09:00
ec259c981c 디바이스 메인 템플릿 스타일 적용 2025-04-25 13:28:36 +09:00
d4d30f1982 Merge remote-tracking branch 'origin/main' into develop_mh 2025-04-25 11:16:30 +09:00
60138964cf SMS 추가 (테스트 용) 2025-04-25 11:14:20 +09:00
9ce0efdb8c 버그수정 2025-04-25 10:41:31 +09:00
0047ed9587 Merge remote-tracking branch 'origin/main' into develop_mh 2025-04-25 10:02:27 +09:00
d9ad314b62 모드버스 cli 코드 통합 2025-04-25 09:58:43 +09:00
40f51173aa Merge branch 'main' into develop_mh 2025-04-25 09:57:57 +09:00
e53449e001 seed 변경 2025-04-23 11:20:51 +09:00
bdd562f0dd lint 2025-04-23 11:18:52 +09:00
cda965cb81 로그 수정 2025-04-23 11:13:24 +09:00
77c840064c 로그 수정 2025-04-23 11:10:13 +09:00
31663fb9df 버그 수정 2025-04-23 11:07:23 +09:00
cbf28071b0 릴레이션 추가 2025-04-23 11:04:29 +09:00
760561b10e 버그 수정 2025-04-23 11:01:35 +09:00
2434c5b7aa 컨트롤러 2개 조절 2025-04-23 10:58:50 +09:00
9291a5d1db 공조기 두개 연결 2025-04-23 09:53:54 +09:00
ddb0d6a699 Lint 적용 2025-04-23 03:46:36 +09:00
a9b0fe80e4 디자인 템플릿 작업 시작 2025-04-23 03:45:57 +09:00
fbf93e80ae 공조기 ON 후 2초 딜레이 2025-04-21 10:50:41 +09:00
95a126953b on_off 주소 변경 2025-04-21 10:30:58 +09:00
30aa429c76 logger 적용 2025-04-21 06:48:44 +09:00
423460df66 타임존 설정 2025-04-20 22:32:05 +09:00
cb19bc990d time.current -> time.now 로 전부 수정 2025-04-20 08:51:51 +09:00
a958915d41 rufus-scheduler 수정 2025-04-19 06:38:46 +09:00
9ca6e10ac1 rufus-scheduler 수정 2025-04-19 06:37:09 +09:00
def8d28c3b rufus-scheduler 수정 2025-04-19 06:13:12 +09:00
cf3dae63f3 rufus-scheduler 적용 2025-04-19 06:02:27 +09:00
f7131c827f 슬립 위치 변경 2025-04-19 05:52:21 +09:00
32d0ac69c9 스케쥴 추가 기능 구현 2025-04-19 00:07:28 +09:00
eb3bc33509 분 추가 (스케쥴 new 추가 필요) 2025-04-18 15:54:19 +09:00
cd7620672f 파미트리에 root_path link 추가 2025-04-18 07:19:09 +09:00
4a3d0313a2 불필요한 코드 정리 및 modal 추가 2025-04-18 07:13:56 +09:00
8041453c5e lint 2025-04-17 17:05:36 +09:00
6209b926e2 noti 스타일 수정 2025-04-17 11:22:27 +09:00
09068d9f70 Merge remote-tracking branch 'origin/develop_mh' 2025-04-17 17:07:06 +09:00
a479f5da7c 추가 개발 내용 merge 2025-04-17 10:52:01 +09:00
d33c49d1e1 Merge remote-tracking branch 'origin/main' into develop_mh
# Conflicts:
#	app/views/modbus/index.html.erb
#	app/views/modbus/schedule_edit.html.erb
2025-04-17 10:30:48 +09:00
578f41c212 헤더 타이머 추가 2025-04-17 04:51:57 +09:00
f90962c54f 화면 레이아웃 구성 2025-04-17 04:14:43 +09:00
59 changed files with 1234 additions and 175 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

12
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="development.sqlite3" uuid="d3ab71a5-ebb0-4573-9e48-bc4bd013b015">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/storage/development.sqlite3</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

7
.idea/dictionaries/rubyon.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="rubyon">
<words>
<w>farmitry</w>
</words>
</dictionary>
</component>

View File

@@ -53,6 +53,7 @@
<orderEntry type="library" scope="PROVIDED" name="builder (v3.3.0, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="bundler (v2.6.6, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="capybara (v3.40.0, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="ccutrer-serialport (v1.1.0, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="concurrent-ruby (v1.3.5, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="connection_pool (v2.5.0, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="crass (v1.0.6, rbenv: 3.4.2) [gem]" level="application" />
@@ -66,6 +67,7 @@
<orderEntry type="library" scope="PROVIDED" name="erb_lint (v0.9.0, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="erubi (v1.13.1, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="et-orbi (v1.2.11, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="ffi (v1.17.2, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="foreman (v0.88.1, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="fugit (v1.11.1, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="globalid (v1.2.1, rbenv: 3.4.2) [gem]" level="application" />
@@ -129,6 +131,7 @@
<orderEntry type="library" scope="PROVIDED" name="rubocop-rails-omakase (v1.1.0, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="ruby-progressbar (v1.13.0, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="rubyzip (v2.4.1, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="rufus-scheduler (v3.9.2, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="securerandom (v0.4.1, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="selenium-webdriver (v4.31.0, rbenv: 3.4.2) [gem]" level="application" />
<orderEntry type="library" scope="PROVIDED" name="smart_properties (v1.17.0, rbenv: 3.4.2) [gem]" level="application" />

6
.idea/sqldialects.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/app/controllers/schedules_controller.rb" dialect="GenericSQL" />
</component>
</project>

View File

@@ -71,3 +71,4 @@ end
gem "ccutrer-serialport"
gem "rmodbus"
gem "rufus-scheduler"

View File

@@ -129,6 +129,8 @@ GEM
et-orbi (1.2.11)
tzinfo
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
foreman (0.88.1)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
@@ -192,18 +194,10 @@ GEM
nio4r (2.7.4)
nokogiri (1.18.7-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.1)
parallel (1.27.0)
parser (3.3.8.0)
@@ -303,6 +297,8 @@ GEM
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
rubyzip (2.4.1)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
securerandom (0.4.1)
selenium-webdriver (4.31.0)
base64 (~> 0.2)
@@ -328,12 +324,8 @@ GEM
railties (>= 7.1)
thor (~> 1.3.1)
sqlite3 (2.6.0-aarch64-linux-gnu)
sqlite3 (2.6.0-aarch64-linux-musl)
sqlite3 (2.6.0-arm-linux-gnu)
sqlite3 (2.6.0-arm-linux-musl)
sqlite3 (2.6.0-arm64-darwin)
sqlite3 (2.6.0-x86_64-linux-gnu)
sqlite3 (2.6.0-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
@@ -377,13 +369,8 @@ GEM
PLATFORMS
aarch64-linux
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin-24
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
amazing_print
@@ -403,6 +390,7 @@ DEPENDENCIES
rails (~> 8.0.2)
rmodbus
rubocop-rails-omakase
rufus-scheduler
selenium-webdriver
solid_cable
solid_cache

View File

@@ -0,0 +1,5 @@
<svg width="189" height="189" viewBox="0 0 189 189" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M187.296 112.455C178.909 156.06 140.551 189 94.5 189C42.8066 189 0.807589 147.494 0.0114746 95.99C3.71633 92.1456 7.55581 88.7053 11.4659 85.6265C11.1579 88.5422 11 91.5027 11 94.5C11 140.616 48.3842 178 94.5 178C126.824 178 154.858 159.633 168.736 132.765C176.362 127.258 182.483 120.223 187.296 112.455ZM188.892 89.9371C185.31 93.8915 181.585 97.4466 177.777 100.643C177.925 98.6143 178 96.5659 178 94.5C178 48.3843 140.616 11 94.5 11C63.5437 11 36.5219 27.8457 22.1015 52.8701C14.5028 57.4406 8.23356 63.4529 3.13478 70.2708C13.8339 29.8192 50.6846 0 94.5 0C145.161 0 186.511 39.8653 188.892 89.9371Z" fill="#158078"/>
<path d="M128 59C73.6 23 25 55.6667 7.5 76.5C7.5 76.5 44 50.0086 88.5 59C21 66 1.5 95.5 1.5 95.5L7.5 105.5L16 99.5C16 99.5 25 110 47.5 105.5C70 101 101.5 55 128 59Z" fill="#158078"/>
<path d="M60 128.621C114.4 164.621 163 131.954 180.5 111.121C180.5 111.121 144 137.613 99.5 128.621C167 121.621 186.5 92.1211 186.5 92.1211L180.5 82.1211L172 88.1211C172 88.1211 163 77.6211 140.5 82.1211C118 86.6211 86.5 132.621 60 128.621Z" fill="#158078"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -2,14 +2,21 @@
@theme {
--color-default: rgb(226, 232, 240);
--color-defaultSlate: rgb(100, 116, 139);
--color-defaultSlateDark: rgb(71, 85, 105);
--color-default-slate: rgb(100, 116, 139);
--color-default-slate-dark: rgb(71, 85, 105);
--color-primary: rgb(66, 165, 245);
--color-notice: rgb(19, 198, 154);
--color-warning: rgb(255, 202, 40);
--color-accept: rgb(121, 134, 203);
--color-danger: rgb(239, 83, 80);
--color-tableBorder: rgb(203, 213, 225);
--color-table-border: rgb(130, 144, 158);
/* theme */
--color-logo-color: rgb(21, 128, 120);
--color-base-background: rgb(39, 44, 56);
--color-base-secondary: rgb(55, 61, 71);
--color-base-text: rgb(255, 255, 255);
--color-base-border: rgb(77, 84, 102);
--base-font-size: 0.85rem;
--text-xs: calc(var(--base-font-size) * 0.75);
@@ -42,11 +49,21 @@
@layer base {
body {
@apply text-sm text-defaultSlateDark
@apply bg-base-background text-base-text font-['Pretendard-Regular']
}
hr {
@apply border-tableBorder
@apply border-table-border
}
.background-pattern {
background-image: repeating-linear-gradient(
-45deg,
rgba(255, 255, 255, 0.01) 0,
rgba(255, 255, 255, 0.01) 24px,
transparent 1px,
transparent 36px
);
}
a.disabled {
@@ -58,33 +75,59 @@
}
.btn {
@apply justify-center rounded-xs bg-defaultSlate px-2 py-0.5 text-sm font-semibold leading-6 text-white
@apply justify-center rounded-md bg-default-slate px-4 py-2 font-semibold leading-6 text-white
shadow-xs hover:opacity-80 cursor-pointer
}
.card {
@apply bg-white border border-tableBorder rounded-xs shadow-xs
@apply bg-white border border-table-border rounded-xs shadow-xs
}
/*Form*/
.label-style {
@apply block my-1 font-bold text-defaultSlateDark
@apply block my-1 font-bold text-default-slate-dark
}
.select-style {
@apply block w-full h-8 px-3 py-1.5 border border-defaultSlate/50 rounded-xs
@apply block w-full px-2 py-1.5 border border-default-slate/50 rounded-xs text-base-background appearance-none
}
.select-style option {
color: theme('colors.base-background');
}
.input-style {
@apply block w-full h-8 px-3 py-1.5 border border-defaultSlate/50 rounded-xs
@apply block w-full px-2 py-1.5 border border-default-slate/50 rounded-xs
}
.textarea-style {
@apply block w-full pl-4 flex-1 py-1.5 ring-1 ring-inset ring-defaultSlate/50 rounded-xs
@apply block w-full pl-4 flex-1 py-1.5 ring-1 ring-inset ring-default-slate/50 rounded-xs
}
/*Toggle Button*/
.toggle_style {
@apply w-14 h-8 bg-danger rounded-full peer-checked:bg-notice transition-all duration-300
}
.toggle_btn {
@apply absolute left-1 top-1 w-6 h-6 bg-white rounded-full transition-all duration-300 peer-checked:translate-x-full shadow-sm
}
}
@layer utilities {
.header {
@apply p-4 text-lg font-bold h-16 flex justify-between items-center;
}
.side-bar {
/*@apply w-[16rem] pl-4 text-lg font-bold divide-y divide-base-border;*/
@apply w-[16rem] pl-4 pr-4 lg:pr-0 text-lg font-bold;
}
.content {
@apply lg:rounded-lg bg-base-secondary;
}
.page-title {
@apply text-xl font-bold;
}
@@ -94,7 +137,7 @@
}
.form-title {
@apply text-center text-3xl font-bold tracking-tight text-defaultSlateDark
@apply text-center text-3xl font-bold tracking-tight text-default-slate-dark
}
.field {
@@ -107,15 +150,23 @@
/*Menu*/
.menu-group {
@apply py-2 px-4
@apply flex flex-row py-2 px-4 my-4 gap-x-4 justify-between items-center w-full
}
.menu-group-title {
@apply py-1 text-sm text-defaultSlateDark font-medium
.menu-group-active {
@apply bg-base-secondary text-white rounded-md;
}
.menu-group-items {
@apply p-1
.menu-group:hover {
@apply bg-base-secondary text-white transition-colors rounded-md;
}
.menu-group-icon {
@apply py-1 text-sm font-medium justify-center
}
.menu-group-name {
@apply p-1 flex-1
}
.menu-group-items-title {
@@ -123,16 +174,21 @@
}
.copyright {
@apply text-defaultSlateDark text-center
@apply text-default-slate-dark text-center
}
/*Dashboard*/
.dashboard-group {
@apply rounded-md bg-base-background h-32 p-4 flex flex-col justify-between cursor-pointer
}
/*Pagy*/
.pagy {
@apply flex items-center justify-center space-x-1 font-semibold text-sm text-defaultSlate;
@apply flex items-center justify-center space-x-1 font-semibold text-sm text-default-slate;
}
.pagy a:not(.gap) {
@apply block rounded-xs px-2 py-1 border border-defaultSlate/50;
@apply block rounded-xs px-2 py-1 border border-default-slate/50;
}
.pagy a:not(.gap):hover {
@@ -140,11 +196,11 @@
}
.pagy a:not(.gap):not([href]) {
@apply text-defaultSlate/50 border border-defaultSlate/25 cursor-default
@apply text-default-slate/50 border border-default-slate/25 cursor-default
}
.pagy a:not(.gap).current {
@apply text-white bg-defaultSlate
@apply text-white bg-default-slate
}
.pagy a.gap {
@@ -161,15 +217,15 @@
/*Base Table*/
.base-table {
@apply min-w-full divide-y divide-tableBorder whitespace-nowrap
@apply min-w-full whitespace-nowrap border border-table-border
}
.base-table th {
@apply bg-default px-2 py-2 text-left font-semibold text-defaultSlateDark lg:table-cell
@apply bg-base-background px-2 py-2 text-left font-bold lg:table-cell
}
.base-table td {
@apply border-t border-tableBorder px-2 py-1 text-defaultSlate lg:table-cell whitespace-nowrap
@apply border-t border-table-border px-2 py-1 lg:table-cell whitespace-nowrap
}
}
@@ -182,3 +238,49 @@
@apply text-lg font-bold;
}
}
/* 기본 스크롤바 스타일 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
padding: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(21, 21, 21, 0.4);
border-radius: 4px;
transition: background-color 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(100, 100, 100, 0.6);
}
.input-style::-webkit-outer-spin-button,
.input-style::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.input-style[type="number"] {
-moz-appearance: textfield;
}
/*Fonts*/
@font-face {
font-family: 'GongGothicMedium';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_20-10@1.0/GongGothicLight.woff') format('woff');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'Pretendard-Regular';
src: url('https://fastly.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
}

View File

@@ -1,4 +1,11 @@
class ApplicationController < ActionController::Base
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
before_action :set_controllers
private
def set_controllers
@controllers = Controller.all
end
end

View File

@@ -0,0 +1,2 @@
class DeviceController < ApplicationController
end

View File

@@ -0,0 +1,6 @@
class ModalsController < ApplicationController
def open
@schedule = Schedule.new
render partial: "partials/modals_open", locals: { type: params[:type], id: params[:id], close_button: params[:close_button] }
end
end

View File

@@ -1,37 +1,2 @@
class ModbusController < ApplicationController
def index
@schedule = Schedule.all
@modbus_running = Modbus::PollingService.running?
end
def start
Modbus::PollingService.start
redirect_to modbus_index_path
end
def stop
Modbus::PollingService.stop
redirect_to modbus_index_path
end
def schedule_edit
@schedule = Schedule.all
end
def schedule_edit_update
error_hours = []
params[:schedule].each do |id, attributes|
schedule = Schedule.find_by(id: id)
unless schedule.update(temperature: attributes[:temperature])
error_hours << "#{schedule.hour}시"
end
end
if error_hours.any?
redirect_to modbus_index_path, alert: "#{error_hours.join(', ')}의 온도 업데이트에 실패하였습니다."
else
redirect_to modbus_index_path, notice: "스케줄이 성공적으로 업데이트되었습니다."
end
end
end

View File

@@ -0,0 +1,80 @@
class SchedulesController < ApplicationController
def index
@controllers = Controller.all
end
def view
@controller = Controller.find(params[:id])
@schedule = Schedule.where(controller_id: params[:id]).order(:hour, :minute)
end
def new
@schedule = Schedule.new
end
def create
@schedule = Schedule.new(schedule_params)
if @schedule.save
redirect_to schedule_edit_schedule_path(@schedule.controller_id), notice: "스케쥴이 추가 되었습니다."
else
error_messages = @schedule.errors.full_messages.join(", ")
redirect_to schedule_edit_schedule_path(@schedule.controller_id), alert: "스케쥴 추가 실패: #{error_messages}"
end
end
def destroy
@schedule = Schedule.find_by(id: params[:id])
if @schedule.destroy
redirect_to schedule_edit_schedule_path(@schedule.controller_id), notice: "스케줄이 삭제되었습니다."
else
error_messages = @schedule.errors.full_messages.join(", ")
redirect_to schedule_edit_schedule_path(@schedule.controller_id), alert: "스케쥴 삭제 실패: #{error_messages}"
end
end
def reset
Schedule.where(controller_id: params[:id]).destroy_all
ActiveRecord::Base.connection.execute("DELETE FROM sqlite_sequence WHERE name='schedules'")
redirect_to schedule_edit_schedule_path(params[:id]), notice: "스케줄이 초기화되었습니다."
end
def schedule_edit
@controller = Controller.find(params[:id])
@schedule = Schedule.where(controller_id: params[:id]).order(:hour, :minute)
end
def schedule_edit_update
error_messages = []
params[:schedule]&.each do |id, attributes|
schedule = Schedule.find_by(id: id)
next unless schedule
unless schedule.update(
hour: attributes[:hour],
minute: attributes[:minute],
is_active: attributes[:is_active],
temperature: attributes[:temperature]
)
error_detail = schedule.errors.full_messages.join(", ")
time_label = "#{attributes[:hour]}시 #{attributes[:minute]}분"
error_messages << "#{time_label} - #{error_detail}"
end
end
if error_messages.any?
redirect_to schedule_edit_schedule_path(params[:controller_id]), alert: error_messages.join("<br>")
else
redirect_to schedule_edit_schedule_path(params[:controller_id]), notice: "스케줄이 성공적으로 업데이트되었습니다."
end
end
private
def schedule_params
params.require(:schedule).permit(:controller_id, :hour, :minute, :is_active, :temperature)
end
end

View File

@@ -0,0 +1,2 @@
class WebController < ApplicationController
end

View File

@@ -0,0 +1,2 @@
module DeviceHelper
end

View File

@@ -0,0 +1,2 @@
module ModalsHelper
end

View File

@@ -0,0 +1,2 @@
module SchedulesHelper
end

View File

@@ -0,0 +1,2 @@
module WebHelper
end

View File

@@ -6,3 +6,12 @@ import { application } from "./application"
import HelloController from "./hello_controller"
application.register("hello", HelloController)
import ModalsController from "./modals_controller"
application.register("modals", ModalsController)
import SidebarController from "./sidebar_controller"
application.register("sidebar", SidebarController)
import TimerController from "./timer_controller"
application.register("timer", TimerController)

View File

@@ -0,0 +1,52 @@
import { Controller } from "@hotwired/stimulus"
import { useClickOutside } from 'stimulus-use'
// Connects to data-controller="modals"
export default class extends Controller {
connect() {
// useClickOutside(this)
this.boundHandleKeydown = this.handleKeydown.bind(this)
document.addEventListener("keydown", this.boundHandleKeydown)
window.addEventListener('modals:close', this.close.bind(this))
const panelElement = document.getElementById("modals-panel")
const backdropElement = document.getElementById("modals-backdrop")
if (panelElement && backdropElement) {
setTimeout(() => {
panelElement.classList.remove('opacity-0', 'translate-y-4', 'sm:scale-95')
panelElement.classList.add('opacity-100', 'translate-y-0', 'sm:scale-100')
backdropElement.classList.remove('opacity-0')
backdropElement.classList.add('opacity-75')
}, 10) // 애니메이션이 바로 시작되도록 약간의 지연 추가
}
}
disconnect() {
document.removeEventListener('keydown', this.boundHandleKeydown)
}
handleKeydown(event) {
if (event.key === "Escape") {
this.close()
}
}
close(event) {
const panelElement = document.getElementById("modals-panel")
const backdropElement = document.getElementById("modals-backdrop")
if (panelElement && backdropElement) {
panelElement.classList.remove("opacity-100", "translate-y-0", "sm:scale-100")
panelElement.classList.add("opacity-0", "translate-y-4", "sm:scale-95")
backdropElement.classList.remove("opacity-75")
backdropElement.classList.add("opacity-0")
setTimeout(() => {
const modalsFrame = document.querySelector("turbo-frame#modals")
modalsFrame.innerHTML = ""
}, 300) // 닫힘 애니메이션 시간과 동일하게 300ms 지연 후 모달 제거
}
}
}

View File

@@ -0,0 +1,15 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["menu", "overlay"]
toggle() {
this.menuTarget.classList.toggle("-translate-x-full")
this.overlayTarget.classList.toggle("hidden")
}
close() {
this.menuTarget.classList.add("-translate-x-full")
this.overlayTarget.classList.add("hidden")
}
}

View File

@@ -0,0 +1,21 @@
import {Controller} from "@hotwired/stimulus"
// Connects to data-controller="timer"
export default class extends Controller {
static targets = ["output"]
connect() {
this.updateTime()
this.interval = setInterval(() => this.updateTime(), 1000)
}
disconnect() {
clearInterval(this.interval)
}
updateTime() {
const now = new Date()
this.outputTarget.textContent = `${now.getFullYear()}년 ${String(now.getMonth() + 1).padStart(2, '0')}월 ${String(now.getDate()).padStart(2, '0')}일 ` +
`${String(now.getHours()).padStart(2, '0')}시 ${String(now.getMinutes()).padStart(2, '0')}분 ${String(now.getSeconds()).padStart(2, '0')}초`
}
}

3
app/models/controller.rb Normal file
View File

@@ -0,0 +1,3 @@
class Controller < ApplicationRecord
has_many :schedules
end

View File

@@ -1,6 +1,11 @@
class Schedule < ApplicationRecord
belongs_to :controller
validates :hour, presence: { message: "시간을 입력하세요" }
validates :minute, presence: { message: "분을 입력하세요" }
validates :temperature,
presence: true,
numericality: true,
format: { with: /\A\d+(\.\d)?\z/ }
presence: { message: "온도를 입력하세요" },
numericality: { message: "온도는 숫자여야 합니다" },
format: { with: /\A\d+(\.\d)?\z/, message: "온도는 소수점 한 자리까지 입력 가능합니다" }
validates :minute, uniqueness: { scope: [ :controller_id, :hour ], message: "같은 시:분의 스케쥴이 이미 존재합니다" }
end

View File

@@ -1,47 +1,60 @@
# polling_service.rb
require "rufus-scheduler"
module Modbus
class PollingService
class << self
def start
return if $modbus_polling_threads.any?(&:alive?)
return if @scheduler
thread = Thread.new do
puts "[#{Time.current}] Modbus polling service 시작됨"
last_logged_hour = nil
loop do
begin
puts "[#{Time.now}] Modbus polling service 시작됨 (Rufus)"
@scheduler ||= Rufus::Scheduler.new
@scheduler.cron "0 * * * * *" do
now = Time.now
current_hour = now.hour
if current_hour != last_logged_hour && now.min == 0
schedule = Schedule.find_by(hour: current_hour)
serial_path = Rails.root.join("serial.rb")
system("ruby", serial_path.to_s, "#{schedule.temperature * 10}")
puts "[Schedule] #{current_hour}:00 -> Target temp: #{schedule.temperature}°C"
last_logged_hour = current_hour
current_time = format("%02d:%02d", now.hour, now.min)
puts "# current time: #{current_time}.#{now.sec}"
schedules = Schedule.where(hour: now.hour, minute: now.min)
schedules.each { |schedule| apply_schedule(schedule) }
end
end
rescue StandardError => e
error_message = "[#{Time.current}] 오류: #{e.message}"
private
def apply_schedule(schedule)
if schedule.is_active
run_script(
"power",
"#{schedule.controller.station_id}",
"0",
"[Schedule] #{schedule.controller.name} ON"
)
sleep 2
run_script(
"temp",
"#{schedule.controller.station_id}",
(schedule.temperature * 10).to_i.to_s,
"[Schedule] #{schedule.controller.name} #{format('%02d:%02d', schedule.hour, schedule.minute)} → #{schedule.temperature}°C"
)
else
run_script(
"power",
"#{schedule.controller.station_id}",
"1",
"[Schedule] #{schedule.controller.name} OFF"
)
end
end
def run_script(mode, controller_id, value, success_msg)
if system("ruby", "modbus.rb", mode, controller_id, value)
puts success_msg
Rails.logger.info success_msg
else
error_message = "[#{Time.now}] 실행 실패 (station_id: #{controller_id}, value: #{value})"
puts error_message
ensure
sleep 0.1
Rails.logger.error error_message
end
end
end
$modbus_polling_threads << thread
end
def stop
$modbus_polling_threads.each { |t| t.kill if t.alive? }
$modbus_polling_threads.clear
puts "[#{Time.now}] Modbus polling service 중지됨"
end
def running?
$modbus_polling_threads.any?(&:alive?)
end
end
end
end

View File

@@ -0,0 +1 @@
<div class="p-4">DEVICE</div>

View File

@@ -20,11 +20,31 @@
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
</head>
<body>
<main class="m-4">
<body class="background-pattern min-h-screen min-w-full overflow-x-auto h-full whitespace-nowrap">
<%= turbo_frame_tag :modals %>
<main class="flex flex-col h-full" data-controller="sidebar">
<%= render "partials/header" %>
<div class="flex flex-row flex-1 w-full">
<%= render "partials/sidebar" %>
<div class="flex-1 h-full pb-0 px-0 lg:pb-4 lg:px-4">
<div class="flex-1 h-full content">
<% if flash[:notice] %>
<div class="rounded-tl-md rounded-tr-md px-4 py-2 bg-accept text-white">
<%= raw flash[:notice] %>
</div>
<% elsif flash[:alert] %>
<div class="rounded-tl-md rounded-tr-md px-4 py-2 bg-danger text-white">
<%= raw flash[:alert] %>
</div>
<% end %>
<%= yield %>
</div>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "Farmitry Hvac" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
</head>
<body class="h-screen overflow-hidden bg-base-background text-base-text min-w-[400px] overflow-x-auto">
<%= turbo_frame_tag :modals %>
<main class="flex flex-col h-full">
<%= render "partials/header" %>
<div class="flex flex-row flex-1 w-full overflow-hidden">
<%= render "partials/sidebar" %>
<div class="flex-1 h-full pb-4 pr-4">
<div class="flex-1 h-full content">
<% if flash[:notice] %>
<div class="m-4 rounded px-4 py-2 bg-accept text-white">
<%= raw flash[:notice] %>
</div>
<% elsif flash[:alert] %>
<div class="m-4 rounded px-4 py-2 bg-danger text-white">
<%= raw flash[:alert] %>
</div>
<% end %>
<%= yield %>
</div>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "Farmitry Hvac" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
</head>
<body>
<%= turbo_frame_tag :modals %>
<main class="flex flex-col h-full divide-y divide-border-table-border">
<%= render "partials/header" %>
<div class="flex flex-row flex-1 w-full divide-x divide-border-table-border">
<div class="w-full h-full">
<% if flash[:notice] %>
<div class="m-4 rounded px-4 py-2 bg-accept text-white">
<%= raw flash[:notice] %>
</div>
<% elsif flash[:alert] %>
<div class="m-4 rounded px-4 py-2 bg-danger text-white">
<%= raw flash[:alert] %>
</div>
<% end %>
<%= yield %>
</div>
</div>
</main>
</body>
</html>

View File

@@ -1,36 +0,0 @@
<meta http-equiv="refresh" content="5">
<% if flash[:notice] %>
<div class="mb-4 rounded bg-green-100 px-4 py-2 text-green-800 border border-green-300">
<%= flash[:notice] %>
</div>
<% elsif flash[:alert] %>
<div class="mb-4 rounded bg-red-100 px-4 py-2 text-red-800 border border-red-300">
<%= flash[:alert] %>
</div>
<% end %>
<% @schedule.each do |s| %>
<div class="flex justify-items-center gap-x-2">
<div><%= s.hour %>시</div>
<div><%= s.temperature %> °C</div>
</div>
<% end %>
<div class="py-4">
<%= button_to "수정", schedule_edit_modbus_index_path, method: :get, class: "btn bg-primary" %>
</div>
<div class="flex gap-x-2">
<%= button_to "스케쥴러 시작", start_modbus_index_path, method: :post, class: "btn bg-accept" %>
<%= button_to "스케쥴러 정지", stop_modbus_index_path, method: :post, class: "btn bg-danger" %>
</div>
<div class="py-4">
<span class="font-semibold">스케쥴러 상태:</span>
<% if @modbus_running %>
<span class="text-accept font-bold">실행 중</span>
<% else %>
<span class="text-danger font-bold">정지됨</span>
<% end %>
</div>

View File

@@ -1,14 +0,0 @@
<%= form_with url: schedule_edit_update_modbus_index_path, method: :post do %>
<% @schedule.each do |s| %>
<div class="flex justify-items-center gap-x-2 pb-2">
<div><%= s.hour %>시</div>
<div>
<%= number_field_tag "schedule[#{s.id}][temperature]", s.temperature, step: "0.1", inputmode: "decimal", class: "border px-2 py-1 rounded" %> °C
</div>
</div>
<% end %>
<div class="pt-4">
<%= submit_tag "업데이트", class: "bg-blue-500 text-white px-4 py-2 rounded" %>
</div>
<% end %>

View File

@@ -0,0 +1,14 @@
<header class="flex header justify-between items-center" data-controller="timer">
<div class="flex flex-row h-full items-center">
<%= image_tag "svg/svg_logo.svg", class: "w-auto h-8 mr-4" %>
<%= link_to root_path do %>
<div class="flex h-full items-center tracking-[0.5rem]"><span class="text-logo-color">FARM</span>ITRY</div>
<% end %>
</div>
<div data-timer-target="output" class="hidden lg:block"></div>
<button data-action="click->sidebar#toggle" class="block lg:hidden p-2 cursor-pointer">
<i class="fa-solid fa-bars text-2xl"></i>
</button>
</header>

View File

@@ -0,0 +1,44 @@
<%= turbo_frame_tag :modals do %>
<div class="relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<!--
Background backdrop, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div id="modals-backdrop" class="fixed inset-0 bg-base-background transition-opacity opacity-0 ease-out duration-500" aria-hidden="true"></div>
<div class="fixed inset-0 z-50 w-screen overflow-y-auto py-4">
<div class="flex min-h-full items-center justify-center text-center sm:items-center">
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
-->
<div id="modals-panel" class="p-4 space-y-4 z-50 relative transform overflow-hidden rounded-md bg-base-secondary text-left shadow-xl transition-all w-full max-w-full mx-2 sm:mx-10 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95 ease-out duration-500" data-controller="modals" data-action="modals:click:outside->modals#close">
<div>
<%= render "partials/modals/#{type}" %>
</div>
<% if close_button != "false" %>
<%= button_tag type: 'button',
data: {
action: 'modals#close'
},
class: 'inline-flex btn w-full' do %>
<div class="items-center"><i class="fa-solid fa-square-caret-down"></i> 닫기</div>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,31 @@
<div data-sidebar-target="overlay" data-action="click->sidebar#close" class="fixed inset-0 bg-black/50 z-40 hidden lg:hidden"></div>
<nav class="side-bar overflow-y-auto transition-transform lg:translate-x-0 -translate-x-full bg-base-background lg:bg-transparent fixed lg:relative top-0 left-0 h-full z-40" data-sidebar-target="menu">
<%= link_to root_path, class: "menu-group #{'menu-group-active' if current_page?(root_path)}" do %>
<div class="menu-group-icon">
<i class="fa-solid fa-chart-line text-base-text text-4xl"></i>
</div>
<div class="menu-group-name">
현황
</div>
<% end %>
<hr >
<% @controllers.each do |c| %>
<%= link_to view_schedule_path(c.id), class: "menu-group #{'menu-group-active' if current_page?(view_schedule_path(c.id))}" do %>
<div class="menu-group-icon">
<i class="fa-solid fa-temperature-high text-base-text text-4xl"></i>
</div>
<div class="menu-group-name">
<%= c.name %>
</div>
<% end %>
<hr >
<% end %>
<div class="menu-group">
<div class="menu-group-icon">
<i class="fa-solid fa-gear text-base-text text-4xl"></i>
</div>
<div class="menu-group-name">
설청
</div>
</div>
</nav>

View File

@@ -0,0 +1,41 @@
<%= form_with model: @schedule, method: :post, data: { turbo: false }, class: 'flex flex-col h-full space-y-4' do |form| %>
<%= form.hidden_field :controller_id, value: params[:id] %>
<div class="flex-1 text-white space-y-4">
<div class="text-2xl font-bold leading-10"><%= Controller.find(params[:id]).name %> 컨트롤러</div>
<table class="base-table">
<thead>
<tr>
<th>시간</th>
<th>분</th>
<th>ON/OFF</th>
<th>온도</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<%= form.select :hour,
options_for_select((0..23).map { |h| [h.to_s.rjust(2, '0'), h] }, @schedule.hour),
{},
class: "select-style text-white" %>
</td>
<td>
<%= form.number_field :minute,
min: 0, max: 59, step: 1, inputmode: "decimal", class: "input-style" %>
</td>
<td>
<%= check_box_tag "schedule[is_active]", "1", true %>
</td>
<td>
<%= form.number_field :temperature,
step: "0.1", inputmode: "decimal", class: "input-style" %>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex">
<%= submit_tag "추가", class: "btn bg-primary" %>
</div>
<% end %>

View File

@@ -0,0 +1,159 @@
<div class="flex-1 grid grid-cols-2 lg:grid-cols-4 gap-4 p-4">
<!-- 실내온도 -->
<div class="dashboard-group">
<div class="flex flex-col justify-between h-full">
<div class="text-xl">실내온도</div>
<div class="flex flex-row justify-between items-baseline w-full">
<i class="fa-solid fa-temperature-quarter text-base-text/80 text-4xl text-left w-full"></i>
<div class="items-baseline text-right w-full">
<span class="text-4xl font-['GongGothicMedium'] font-light">15.5</span>
<span class="text-2xl">°C</span>
</div>
</div>
</div>
</div>
<!-- 실내습도 -->
<div class="dashboard-group">
<div class="flex flex-col justify-between h-full">
<div class="text-xl">실내습도</div>
<div class="flex flex-row justify-between items-baseline w-full">
<i class="fa-solid fa-droplet text-base-text/80 text-4xl text-left w-full"></i>
<div class="items-baseline text-right w-full"><span class="text-4xl font-['GongGothicMedium']">60</span> <span class="text-2xl">%</span>
</div>
</div>
</div>
</div>
<!-- CO2 -->
<div class="dashboard-group">
<div class="flex flex-col justify-between h-full">
<div><span class="text-xl">CO</span><span class="text-sm">2</span></div>
<div class="flex flex-row justify-between items-baseline w-full">
<div class="size-10 bg-base-text/80 rounded-full text-base-background flex flex-col justify-center">
<div class="flex flex-row justify-center items-baseline">
<span class="text-xl">CO</span><span class="text-sm">2</span>
</div>
</div>
<div class="items-baseline text-right"><span class="text-4xl font-['GongGothicMedium']">60</span> <span class="text-2xl">%</span></div>
</div>
</div>
</div>
<!-- none -->
<div class="dashboard-group">1</div>
<!-- Fan -->
<div class="dashboard-group">
<div class="flex flex-col justify-between h-full">
<div class="text-xl">FAN</div>
<div class="flex flex-row justify-between items-baseline w-full">
<i class="fa-solid fa-fan text-base-text/80 text-4xl text-left w-full"></i>
<div class="items-baseline text-right w-full"><span class="text-4xl font-['GongGothicMedium']">60</span> <span class="text-2xl">%</span>
</div>
</div>
</div>
</div>
<!-- 공조기 -->
<div class="dashboard-group">
<div class="flex flex-col justify-between h-full">
<div class="text-xl">공조기</div>
<div class="flex flex-row justify-between items-baseline w-full">
<i class="fa-solid fa-temperature-quarter text-base-text/80 text-4xl text-left w-full"></i>
<div class="items-baseline text-right w-full"><span class="text-4xl font-['GongGothicMedium']">15.5</span>
<span class="text-2xl">°C</span></div>
</div>
</div>
</div>
<!-- LED -->
<div class="dashboard-group">
<div class="flex flex-col justify-between h-full">
<div class="text-xl">LED</div>
<div class="flex flex-row justify-between items-baseline w-full">
<i class="fa-solid fa-lightbulb text-base-text/80 text-4xl text-left w-full"></i>
<div class="items-baseline text-right w-full"><span class="text-4xl font-['GongGothicMedium']">60</span> <span class="text-2xl">%</span>
</div>
</div>
</div>
</div>
<!-- none -->
<div class="dashboard-group">1</div>
<!-- pH -->
<div class="dashboard-group">
<div class="flex justify-between items-baseline">
<div class="text-4xl">pH</div>
<div class="text-3xl font-['GongGothicMedium']">7.1</div>
</div>
<div class="flex justify-between items-baseline">
<div class="text-2xl">
6.0 ~ 6.0
</div>
<div><i class="fa-solid fa-gear text-base-text/30 text-2xl"></i></div>
</div>
</div>
<!-- EC -->
<div class="dashboard-group">
<div class="flex justify-between items-baseline">
<div class="text-4xl">EC</div>
<div class="text-3xl font-['GongGothicMedium']">0.1</div>
</div>
<div class="flex justify-between items-baseline">
<div class="text-2xl">
0 ~ 1
</div>
<div><i class="fa-solid fa-gear text-base-text/30 text-2xl"></i></div>
</div>
</div>
<!-- 양액온도 -->
<div class="dashboard-group">
<div class="flex flex-col justify-between h-full">
<div class="text-xl">양액온도</div>
<div class="flex flex-row justify-between items-baseline w-full">
<!-- <i class="fa-solid fa-temperature-quarter text-base-text/80 text-4xl text-left w-full"></i>-->
<div class="items-baseline text-right w-full"><span class="text-4xl font-['GongGothicMedium']">20.96</span>
<span class="text-2xl">°C</span></div>
</div>
</div>
</div>
<!-- none -->
<div class="dashboard-group">1</div>
<!-- none -->
<div class="dashboard-group">
<div class="flex flex-col justify-center space-y-4 h-full">
<i class="fa-solid fa-recycle text-base-text/80 text-5xl text-center w-full"></i>
<div class="text-xl text-center">자동화</div>
</div>
</div>
<!-- none -->
<div class="dashboard-group">
<div class="flex flex-col justify-center space-y-4 h-full">
<i class="fa-solid fa-rotate-right text-base-text/80 text-5xl text-center w-full"></i>
<div class="text-xl text-center">초기화</div>
</div>
</div>
<!-- none -->
<div class="dashboard-group">
<div class="flex flex-col justify-center space-y-4 h-full">
<i class="fa-solid fa-circle-pause text-base-text/80 text-5xl text-center w-full"></i>
<div class="text-xl text-center">긴급 정지</div>
</div>
</div>
<!-- none -->
<div class="dashboard-group">
<div class="flex flex-col justify-center space-y-4 h-full">
<i class="fa-solid fa-faucet-drip text-base-text/80 text-5xl text-center w-full"></i>
<div class="text-xl text-center">1회 급수 설정</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,81 @@
<div class="flex flex-col h-full text-white overflow-y-auto">
<div class="flex flex-col flex-1 divide-y divide-base-border">
<%= form_with url: schedule_edit_update_schedules_path, method: :post, class: 'flex flex-col h-full' do %>
<%= hidden_field_tag :controller_id, params[:id] %>
<div class="divide-y divide-base-border">
<div class="flex justify-between p-4">
<div class="text-2xl font-bold leading-10"><%= @controller.name %> 컨트롤러</div>
<%= submit_tag "업데이트", class: "btn bg-primary" %>
</div>
<ul class="divide-y divide-base-border whitespace-nowrap h-full relative">
<% if @schedule.present? %>
<% @schedule.each do |s| %>
<li class="flex justify-between items-stretch px-4 py-2 space-x-4 h-full relative">
<div class="flex items-center space-x-4 basis-3/5 h-full">
<!-- 시간 -->
<div class="flex flex-col lg:flex-row h-full items-center space-x-0 space-y-2 lg:space-x-4 lg:space-y-0">
<div class="text-white text-left w-full lg:w-auto">시간</div>
<div class="text-xl flex items-center space-x-1 w-full h-full">
<%= select_tag "schedule[#{s.id}][hour]",
options_for_select((0..23).map { |h| [h.to_s.rjust(2, '0'), h] }, s.hour),
class: "select-style text-white min-w-[60px]" %>
<div>:</div>
<%= number_field_tag "schedule[#{s.id}][minute]",
s.minute, min: 0, max: 59, step: 1, inputmode: "decimal",
class: "input-style min-w-[60px]" %>
</div>
</div>
<!-- 온도 -->
<div class="flex flex-col lg:flex-row h-full items-center space-x-0 space-y-2 lg:space-x-4 lg:space-y-0">
<div class="text-white text-left w-full lg:w-auto">온도 (°C)</div>
<div class="text-xl flex items-center">
<%= number_field_tag "schedule[#{s.id}][temperature]", s.temperature, min: -99, max: 99, step: "0.1", inputmode: "decimal", class: "input-style min-w-[68px]" %>
</div>
</div>
</div>
<div class="flex items-center space-x-4 basis-2/5 justify-end h-full">
<!-- 사용 여부 -->
<div class="flex flex-col lg:flex-row items-center space-x-0 space-y-2 lg:space-x-4 lg:space-y-0 h-full">
<div class="text-white">ON/OFF</div>
<label class="relative inline-flex items-center cursor-pointer h-full">
<%= check_box_tag "schedule[#{s.id}][is_active]", "1", s.is_active == true || s.is_active == 1, class: "sr-only peer" %>
<div class="toggle_style"></div>
<div class="toggle_btn"></div>
</label>
</div>
<!-- 삭제 -->
<div class="flex flex-col lg:flex-row items-center space-x-0 space-y-2 lg:space-x-4 lg:space-y-0 h-full relative">
<div class="text-white">&nbsp;</div>
<%= link_to schedule_path(s),
data: { turbo_method: :delete, turbo_confirm: "정말 삭제하시겠습니까?" },
class: "btn bg-danger text-sm flex items-center px-2 py-0 my-1.5 lg:px-4 lg:py-1.5 lg:my-0" do %>
<div class="hidden lg:block">삭제</div>
<div class="block lg:hidden"><i class="fa-solid fa-xmark"></i></div>
<% end %>
</div>
</div>
</li>
<% end %>
<% else %>
<li class="text-center p-4 w-full">저장된 스케줄이 없습니다.</li>
<% end %>
</ul>
</div>
<% end %>
<div class="flex p-4 space-x-4">
<%= button_to "초기화", reset_schedule_path(params[:id]),
method: :post,
data: { turbo_confirm: "정말 초기화하시겠습니까? 모든 스케줄 데이터가 삭제됩니다." },
class: "btn bg-danger" %>
<%= button_to "추가하기", open_modals_path(type: "add_schedule", id: params[:id]),
class: "btn bg-default-slate",
data: {
turbo_method: :post,
turbo_frame: "modals"
} %>
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
<div class="flex flex-col h-full divide-y divide-base-border text-white">
<div class="flex flex-col flex-1 space-y-4 overflow-y-hidden">
<div class="overflow-y-auto divide-y divide-base-border">
<div class="flex justify-between p-4">
<div class="text-2xl font-bold leading-10"><%= @controller.name %> 컨트롤러</div>
<%= link_to "수정", schedule_edit_schedule_path(@controller.id), class: "btn bg-default-slate" %>
</div>
<ul class="divide-y divide-base-border">
<% if @schedule.present? %>
<% @schedule.each do |s| %>
<li class="flex justify-between items-center px-4 py-2 space-x-4">
<div class="flex items-center space-x-6">
<!-- 시간 -->
<div class="flex flex-col lg:flex-row items-baseline space-x-2">
<div class="text-white">시간</div>
<div class="text-3xl flex items-baseline space-x-1">
<div class="text-center w-[36px]"><%= s.hour.to_s.rjust(2, '0') %></div>
<div>:</div>
<div class="text-center w-[36px]"><%= s.minute.to_s.rjust(2, '0') %></div>
</div>
</div>
<!-- 온도 -->
<div class="flex flex-col lg:flex-row items-baseline space-x-4">
<div class="text-white">온도</div>
<div class="text-3xl flex items-end space-x-2">
<div class="text-left w-[50px]"><%= s.temperature %></div>
<div class="text-white text-base leading-7">°C</div>
</div>
</div>
</div>
<!-- 사용 여부 -->
<div class="flex flex-col lg:flex-row items-baseline space-x-0 lg:space-x-4 items-end">
<div class="text-white mb-1">ON/OFF</div>
<div class="text-3xl w-8 mr-0">
<%#= s.is_active %>
<% if s.is_active %>
<i class="fa-solid fa-check text-notice"></i>
<% else %>
<i class="fa-solid fa-xmark text-danger"></i>
<% end %>
</div>
</div>
</li>
<% end %>
<% else %>
<li class="text-center p-4 w-full">저장된 스케줄이 없습니다.</li>
<% end %>
</ul>
<!-- <table class="base-table">-->
<!-- <thead>-->
<!-- <tr>-->
<!-- <th>시간</th>-->
<!-- <th>분</th>-->
<!-- <th>사용여부</th>-->
<!-- <th>온도</th>-->
<!-- </tr>-->
<!-- </thead>-->
<!-- <tbody>-->
<%# @schedule.each do |s| %>
<!-- <tr>-->
<!-- <td><%#= s.hour %>시</td>-->
<!-- <td><%#= s.minute %>분</td>-->
<!-- <td><%#= s.is_active %></td>-->
<!-- <td><%#= s.temperature %> °C</td>-->
<!-- </tr>-->
<%# end %>
<!-- </tbody>-->
<!-- </table>-->
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
<div>WEB</div>

View File

@@ -28,5 +28,7 @@ module FarmitryHvac
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
config.time_zone = "Asia/Seoul"
end
end

View File

@@ -10,14 +10,26 @@ Rails.application.routes.draw do
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
# Defines the root path route ("/")
root "modbus#index"
resources :modbus do
collection do
post "start"
post "stop"
root "schedules#index"
resources :schedules do
member do
get "view"
get "schedule_edit"
post "reset"
end
collection do
post "schedule_edit_update"
end
end
resources :modals do
collection do
post :open
end
end
resources :device
resources :web
end

View File

@@ -0,0 +1,10 @@
class CreateControllers < ActiveRecord::Migration[8.0]
def change
create_table :controllers do |t|
t.string :name
t.integer :station_id
t.timestamps
end
end
end

View File

@@ -1,10 +1,14 @@
class CreateSchedules < ActiveRecord::Migration[8.0]
def change
create_table :schedules do |t|
t.references :controller, null: false, foreign_key: true
t.integer :hour
t.integer :minute
t.boolean :is_active
t.float :temperature
t.timestamps
end
add_index :schedules, [ :controller_id, :hour, :minute ], unique: true
end
end

20
db/schema.rb generated
View File

@@ -11,10 +11,24 @@
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_04_16_131440) do
create_table "schedules", force: :cascade do |t|
t.integer "hour"
t.float "temperature"
create_table "controllers", force: :cascade do |t|
t.string "name"
t.integer "station_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "schedules", force: :cascade do |t|
t.integer "controller_id", null: false
t.integer "hour"
t.integer "minute"
t.boolean "is_active"
t.float "temperature"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["controller_id", "hour", "minute"], name: "index_schedules_on_controller_id_and_hour_and_minute", unique: true
t.index ["controller_id"], name: "index_schedules_on_controller_id"
end
add_foreign_key "schedules", "controllers"
end

View File

@@ -8,6 +8,10 @@
# MovieGenre.find_or_create_by!(name: genre_name)
# end
Controller.create(name: "3번방", station_id: 7)
Controller.create(name: "2번방", station_id: 8)
(0..23).each do | h |
Schedule.create!(hour: h, temperature: 15.0)
Schedule.create!(controller_id: 1, hour: h, minute: 0, is_active: true, temperature: 15.0)
Schedule.create!(controller_id: 2, hour: h, minute: 0, is_active: true, temperature: 15.0)
end

34
modbus.rb Normal file
View File

@@ -0,0 +1,34 @@
require "rmodbus"
require "ccutrer-serialport"
require './sms/sms'
mode = ARGV[0]&.strip
controller_id = ARGV[1]&.to_i
value = ARGV[2]&.to_i
begin
ModBus::RTUClient.new("/dev/ttyUSB0", 9600) do |cl|
cl.with_slave(controller_id) do |slave|
regs = slave.holding_registers
case mode
when "temp"
regs[2] = value
when "power"
regs[22] = value
else
# type code here
end
sleep 0.1
end
end
rescue
error_message = "[#{Time.now}] #{mode} 실행 실패 (station_id: #{controller_id}, value: #{value})"
res = Sms.send_one(
{
to: '01062619801',
from: '01062619801',
text: error_message
}
)
puts res
end

View File

@@ -12,6 +12,7 @@
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo-rails": "^8.0.13",
"@tailwindcss/cli": "^4.1.4",
"tailwindcss": "^4.1.4"
"tailwindcss": "^4.1.4",
"stimulus-use": "^0.52.2"
}
}

7
sms/config.json Executable file
View File

@@ -0,0 +1,7 @@
{
"api_key": "NCSK1QGPTBWMUKBO",
"api_secret": "RSGJ20CGUOTRKK33RFONFUFHY2SWMU9X",
"protocol": "https",
"domain": "api.coolsms.co.kr",
"prefix": ""
}

84
sms/request.rb Executable file
View File

@@ -0,0 +1,84 @@
require 'openssl'
require 'base64'
require 'net/http'
require 'json'
require 'securerandom'
module Request
@file = File.read File.join(File.dirname(__FILE__), './config.json')
@config = JSON.parse(@file)
def self.get_uri(path)
str = @config['protocol'] + '://' + @config['domain'] + @config['prefix']
puts str + path
uri = URI(str + path)
uri
end
def self.header_get
api_key = @config['api_key']
api_secret = @config['api_secret']
date = Time.now.strftime('%Y-%m-%dT%H:%M:%S.%L%z')
salt = SecureRandom.hex
signature = OpenSSL::HMAC.hexdigest('SHA256', api_secret, date + salt)
'HMAC-SHA256 apiKey=' + api_key + ', date=' + date + ', salt=' + salt + ', signature=' + signature
end
def self.post(path, body)
header = header_get
uri = get_uri(path)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
req.add_field('Authorization', header)
if body
req.body = body.to_json
end
res = http.request(req)
JSON.parse(res.body)
end
def self.put(path, body, headers = nil)
auth = header_get
uri = get_uri(path)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Put.new(uri.path, 'Content-Type' => 'application/json')
req.add_field('Authorization', auth)
if headers
headers.each do |k, v|
req.add_field(k, v)
end
end
if body
req.body = body.to_json
end
res = http.request(req)
JSON.parse(res.body)
end
def self.get(path)
header = header_get
uri = get_uri(path)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Get.new(uri.path, 'Content-Type' => 'application/json')
req.add_field('Authorization', header)
res = http.request(req)
JSON.parse(res.body)
end
def self.delete(path, body)
header = header_get
uri = get_uri(path)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Delete.new(uri.path, 'Content-Type' => 'application/json')
req.add_field('Authorization', header)
res = http.request(req)
if body
req.body = body.to_json
end
JSON.parse(res.body)
end
end

11
sms/sms.rb Executable file
View File

@@ -0,0 +1,11 @@
require_relative "request"
module Sms
def self.send_one(message)
Request.post("/messages/v4/send", { message: message })
end
def self.send_many(messages)
Request.post("/messages/v4/send-many", { messages: messages })
end
end

View File

View File

@@ -0,0 +1,7 @@
require "test_helper"
class DeviceControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class ModalsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class SchedulesControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class WebControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

11
test/fixtures/controllers.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# This model initially had no columns defined. If you add columns to the
# model remove the "{}" from the fixture names and add the columns immediately
# below each fixture, per the syntax in the comments below
#
one: {}
# column: value
#
two: {}
# column: value

View File

@@ -0,0 +1,7 @@
require "test_helper"
class ControllerTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -571,6 +571,11 @@ picomatch@^2.3.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
stimulus-use@^0.52.2:
version "0.52.3"
resolved "https://registry.yarnpkg.com/stimulus-use/-/stimulus-use-0.52.3.tgz#d6f35fa93277274957a2ed98a7b04b4d702cb1d6"
integrity sha512-stZ5dID6FUrGCR/ChWUa0FT5Z8iqkzT6lputOAb50eF+Ayg7RzJj4U/HoRlp2NV333QfvoRidru9HLbom4hZVw==
tailwindcss@4.1.4, tailwindcss@^4.1.4:
version "4.1.4"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.1.4.tgz#27b3c910c6f1a47f4540451f3faf7cdd6d977a69"