first commit

This commit is contained in:
2025-04-16 23:15:08 +09:00
commit 74f134c71a
153 changed files with 11803 additions and 0 deletions

0
app/assets/builds/.keep Normal file
View File

0
app/assets/images/.keep Normal file
View File

View File

@@ -0,0 +1,184 @@
@import "tailwindcss";
@theme {
--color-default: rgb(226, 232, 240);
--color-defaultSlate: rgb(100, 116, 139);
--color-defaultSlateDark: 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);
--base-font-size: 0.85rem;
--text-xs: calc(var(--base-font-size) * 0.75);
--text-xs--line-height: calc(1 / var(--text-xs));
--text-sm: calc(var(--base-font-size) * 0.875);
--text-sm--line-height: calc(1.25 / var(--text-sm));
--text-base: calc(var(--base-font-size) * 1);
--text-base--line-height: calc(1.5 / var(--text-base));
--text-lg: calc(var(--base-font-size) * 1.125);
--text-lg--line-height: calc(1.75 / var(--text-lg));
--text-xl: calc(var(--base-font-size) * 1.25);
--text-xl--line-height: calc(1.75 / var(--text-xl));
--text-2xl: calc(var(--base-font-size) * 1.5);
--text-2xl--line-height: calc(2 / var(--text-2xl));
--text-3xl: calc(var(--base-font-size) * 1.875);
--text-3xl--line-height: calc(2.25 / var(--text-3xl));
--text-4xl: calc(var(--base-font-size) * 2.25);
--text-4xl--line-height: calc(2.5 / var(--text-4xl));
--text-5xl: calc(var(--base-font-size) * 3);
--text-5xl--line-height: 1;
--text-6xl: calc(var(--base-font-size) * 3.75);
--text-6xl--line-height: 1;
--text-7xl: calc(var(--base-font-size) * 4.5);
--text-7xl--line-height: 1;
--text-8xl: calc(var(--base-font-size) * 6);
--text-8xl--line-height: 1;
--text-9xl: calc(var(--base-font-size) * 8);
--text-9xl--line-height: 1;
}
@layer base {
body {
@apply text-sm text-defaultSlateDark
}
hr {
@apply border-tableBorder
}
a.disabled {
@apply pointer-events-none cursor-not-allowed opacity-50
}
.disabled {
@apply pointer-events-none cursor-not-allowed opacity-50
}
.btn {
@apply justify-center rounded-xs bg-defaultSlate px-2 py-0.5 text-sm font-semibold leading-6 text-white
shadow-xs hover:opacity-80 cursor-pointer
}
.card {
@apply bg-white border border-tableBorder rounded-xs shadow-xs
}
/*Form*/
.label-style {
@apply block my-1 font-bold text-defaultSlateDark
}
.select-style {
@apply block w-full h-8 px-3 py-1.5 border border-defaultSlate/50 rounded-xs
}
.input-style {
@apply block w-full h-8 px-3 py-1.5 border border-defaultSlate/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
}
}
@layer utilities {
.page-title {
@apply text-xl font-bold;
}
.search-title {
@apply text-base font-bold;
}
.form-title {
@apply text-center text-3xl font-bold tracking-tight text-defaultSlateDark
}
.field {
@apply w-full
}
.btn-edit {
@apply inline-flex items-center rounded-xs px-2.5 py-1.5 text-sm font-semibold hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-white
}
/*Menu*/
.menu-group {
@apply py-2 px-4
}
.menu-group-title {
@apply py-1 text-sm text-defaultSlateDark font-medium
}
.menu-group-items {
@apply p-1
}
.menu-group-items-title {
@apply text-base font-bold
}
.copyright {
@apply text-defaultSlateDark text-center
}
/*Pagy*/
.pagy {
@apply flex items-center justify-center space-x-1 font-semibold text-sm text-defaultSlate;
}
.pagy a:not(.gap) {
@apply block rounded-xs px-2 py-1 border border-defaultSlate/50;
}
.pagy a:not(.gap):hover {
@apply bg-slate-300
}
.pagy a:not(.gap):not([href]) {
@apply text-defaultSlate/50 border border-defaultSlate/25 cursor-default
}
.pagy a:not(.gap).current {
@apply text-white bg-defaultSlate
}
.pagy a.gap {
@apply inline-block my-auto
}
.pagy label {
@apply inline-block whitespace-nowrap bg-default rounded-xs px-3 py-0.5
}
.pagy label input {
@apply bg-default border-none rounded-xs
}
/*Base Table*/
.base-table {
@apply min-w-full divide-y divide-tableBorder whitespace-nowrap
}
.base-table th {
@apply bg-default px-2 py-2 text-left font-semibold text-defaultSlateDark lg:table-cell
}
.base-table td {
@apply border-t border-tableBorder px-2 py-1 text-defaultSlate lg:table-cell whitespace-nowrap
}
}
@media (min-width: 640px) {
.page-title {
@apply text-3xl font-bold;
}
.search-title {
@apply text-lg font-bold;
}
}

View File

@@ -0,0 +1,4 @@
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
end

View File

View File

@@ -0,0 +1,26 @@
class ModbusController < ApplicationController
def index
@schedule = Schedule.all
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,2 @@
module ApplicationHelper
end

View File

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

View File

@@ -0,0 +1,3 @@
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"

View File

@@ -0,0 +1,9 @@
import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}

View File

@@ -0,0 +1,8 @@
// This file is auto-generated by ./bin/rails stimulus:manifest:update
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName
import { application } from "./application"
import HelloController from "./hello_controller"
application.register("hello", HelloController)

View File

@@ -0,0 +1,7 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end

View File

@@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
end

View File

@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end

View File

6
app/models/schedule.rb Normal file
View File

@@ -0,0 +1,6 @@
class Schedule < ApplicationRecord
validates :temperature,
presence: true,
numericality: true,
format: { with: /\A\d+(\.\d)?\z/ }
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "bit_coil", start_address: 0, end_address: 15).execute
module Modbus
module Commands
def bit_coil(slave)
read_bit(slave, :read_coils)
end
end
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "bit_input", start_address: 0, end_address: 15).execute
module Modbus
module Commands
def bit_input(slave)
read_bit(slave, :discrete_inputs)
end
end
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "word_holding_float32", start_address: 0, end_address: 15).execute
module Modbus
module Commands
def word_holding_float32(slave)
read_float(slave, :holding_registers)
end
end
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "word_holding_int16", start_address: 16, end_address: 31).execute
module Modbus
module Commands
def word_holding_int16(slave)
read_int(slave, 16, :holding_registers)
end
end
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "word_input_float32", start_address: 0, end_address: 15).execute
module Modbus
module Commands
def word_input_float32(slave)
read_float(slave, :input_registers)
end
end
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "word_input_int16", start_address: 0, end_address: 15).execute
module Modbus
module Commands
def word_input_int16(slave)
read_int(slave, 16, :input_registers)
end
end
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "write_bit", start_address: 0, value: 1).execute
module Modbus
module Commands
def write_bit(slave)
write_coil(slave)
end
end
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "write_bit_toggle", start_address: 0).execute
module Modbus
module Commands
def write_bit_toggle(slave)
write_toggle_coil(slave)
end
end
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "float32", start_address: 0, value: 123.45).execute
module Modbus
module Commands
def write_float32(slave)
write_float(slave)
end
end
end

View File

@@ -0,0 +1,8 @@
# Modbus::Service.new(plc_id: 1, type: "int16", start_address: 0, value: 123).execute
module Modbus
module Commands
def write_int16(slave)
write_int(slave, 16)
end
end
end

View File

@@ -0,0 +1,93 @@
# app/lib/modbus/operations.rb
module Modbus
module Operations
# write method
def write_toggle_coil(slave)
with_timeout do
target_value = slave.read_coils(@start_address, 1).first == 0 ? 1 : 0
slave.coils[@start_address] = (target_value == 1)
loop do
break if slave.read_coils(@start_address, 1).first == target_value
sleep 0.1
end
end
end
def write_coil(slave)
with_timeout do
slave.coils[@start_address] = @value
loop do
break if slave.read_coils(@start_address, 1).first == @value
sleep 0.1
end
end
end
def write_int(slave, bits)
with_timeout do
regs = slave.holding_registers
if bits == 16
value = @value.present? ? @value.to_i : 123
regs[@start_address] = value
end
end
end
def write_float(slave)
with_timeout do
regs = slave.holding_registers
value = @value.present? ? @value.to_f : 123.45
regs[@start_address], regs[@start_address + 1] = [ value ].pack("f").unpack("v*")
end
end
# read method
def read_bit(slave, type)
with_timeout do
value = case type
when :read_coils
slave.read_coils(@start_address, @end_address - @start_address + 1)
when :discrete_inputs
slave.discrete_inputs[@start_address..@end_address]
else
# type code here
end
Rails.logger.info "[#{Time.current}] Read Bit (#{type}): #{value}"
value
end
end
def read_int(slave, bits, type)
with_timeout do
result = slave.send(type)
values = (@start_address..@end_address).map { |i| result[i] }
Rails.logger.info "[#{Time.current}] Read Float#{bits} (#{type}): #{values}"
values
end
end
def read_float(slave, type)
with_timeout do
result = slave.send(type)
values = []
(@start_address..@end_address).step(2) do |i|
raw = [ result[i], result[i + 1] ]
packed = raw.pack("v*")
float = packed.unpack("f")[0].round(2)
values << float
end
Rails.logger.info "[#{Time.current}] Read Float32 (#{type}): #{values.inspect}"
values
end
end
def with_timeout
seconds = ENV.fetch("OPERATION_TIME_OUT", 1).to_i
Timeout.timeout(seconds) do
yield
end
rescue Timeout::Error
Rails.logger.error "[#{Time.current}] TimeOut (#{seconds}s)"
end
end
end

View File

@@ -0,0 +1,46 @@
module Modbus
class PollingService
class << self
def start
return if @thread&.alive?
@thread = Thread.new do
puts "[#{Time.current}] Modbus polling service 시작됨"
last_logged_hour = nil
loop do
begin
now = Time.now
current_hour = now.hour
if current_hour != last_logged_hour && now.min == 0
schedule = Schedule.find_by(hour: current_hour)
Modbus::Service.new(
type: "write_int16",
start_address: 2,
end_address: 2,
value: schedule.temperature * 10
).execute
puts "[Schedule] #{current_hour}:00 -> Target temp: #{schedule.temperature}°C"
last_logged_hour = current_hour
end
rescue StandardError => e
error_message = "[#{Time.current}] 오류: #{e.message}"
puts error_message
ensure
sleep 0.1
end
end
end
end
def stop
if @thread
@thread.exit
@thread = nil
puts "[#{Time.now}] Modbus polling service 중지됨"
end
end
end
end
end

View File

@@ -0,0 +1,38 @@
require_relative "operations"
Dir[File.join(__dir__.to_s, "commands", "**", "*.rb")].each do |file|
load file
end
class Modbus::Service
include Modbus::Operations
include Modbus::Commands
def initialize(type:, start_address:, end_address: nil, value: nil)
@type = type
@start_address = start_address.to_i
@end_address = end_address&.to_i
@value = value
@slave_id = 1
@plc_host = "rubyon.co.kr"
@plc_port = 502
end
def execute
ModBus::TCPClient.new(@plc_host, @plc_port) do |client|
client.with_slave(@slave_id) do |slave|
method_name = @type.strip
# Commands 모듈에 정의된 메서드만 허용
if Modbus::Commands.instance_methods(false).include?(method_name.to_sym)
return send(method_name, slave)
else
available_commands = Modbus::Commands.instance_methods(false).sort.map(&:to_s)
command_list = available_commands.join("\r\n")
raise ArgumentError, "지원되지 않는 type: #{@type}\r\n사용 가능한 명령어 목록:\r\n#{command_list}"
end
end
end
rescue => e
Rails.logger.error "[#{Time.current}] Service 오류: #{e.message}"
end
end

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "Farmitry Hvac" %></title>
<meta name="viewport" content="width=device-width,initial-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" %>
</head>
<body>
<main class="m-4">
<%= yield %>
</main>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html>

View File

@@ -0,0 +1 @@
<%= yield %>

View File

@@ -0,0 +1,18 @@
<% 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 %>
<%= link_to "수정", "/modbus/schedule_edit", class: "btn btn-primary" %>

View File

@@ -0,0 +1,14 @@
<%= form_with url: "/modbus/schedule_edit_update", method: :post do %>
<% @schedule.each do |s| %>
<div class="flex justify-items-center gap-x-2 pb-2">
<div><%= s.hour %>시</div>
<div>
<%= text_field_tag "schedule[#{s.id}][temperature]", s.temperature, 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,22 @@
{
"name": "FarmitryHvac",
"icons": [
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "FarmitryHvac.",
"theme_color": "red",
"background_color": "red"
}

View File

@@ -0,0 +1,26 @@
// Add a service worker for processing Web Push notifications:
//
// self.addEventListener("push", async (event) => {
// const { title, options } = await event.data.json()
// event.waitUntil(self.registration.showNotification(title, options))
// })
//
// self.addEventListener("notificationclick", function(event) {
// event.notification.close()
// event.waitUntil(
// clients.matchAll({ type: "window" }).then((clientList) => {
// for (let i = 0; i < clientList.length; i++) {
// let client = clientList[i]
// let clientPath = (new URL(client.url)).pathname
//
// if (clientPath == event.notification.data.path && "focus" in client) {
// return client.focus()
// }
// }
//
// if (clients.openWindow) {
// return clients.openWindow(event.notification.data.path)
// }
// })
// )
// })