chore: HERV 통합 저장소 재초기화 커밋
손상된 .git 히스토리(missing tree)로 재초기화 후 작업트리 전체 커밋. .claude/ 만 제외(로컬 에이전트 설정). 구 저장소 백업(.git_corrupt_backup/) 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ErvProtocol;
|
||||
|
||||
namespace ErvCollector.Storage
|
||||
{
|
||||
// 현장별 시계열 로그 (그래프용). InfluxDB 와 별개로 자체 SQLite 단일파일에 누적.
|
||||
// - sample : 현장 1샘플 (시간·전원·운전/자동/풍량/시나리오/프리셋)
|
||||
// - room_sample : 실별 댐퍼/센서/LED/부하
|
||||
// 그래프는 /api/history?site=&date= 로 하루치를 읽어 표시, /api/dates 로 날짜 목록.
|
||||
public sealed class HistoryDb : IDisposable
|
||||
{
|
||||
readonly SqliteConnection _conn;
|
||||
readonly object _lock = new();
|
||||
|
||||
public HistoryDb(string path)
|
||||
{
|
||||
_conn = new SqliteConnection($"Data Source={path}");
|
||||
_conn.Open();
|
||||
Exec("PRAGMA journal_mode=WAL;");
|
||||
Exec("PRAGMA synchronous=NORMAL;");
|
||||
Exec(@"CREATE TABLE IF NOT EXISTS sample(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site TEXT NOT NULL, time INTEGER NOT NULL,
|
||||
power INTEGER, runmode INTEGER, automode INTEGER, fanmode INTEGER,
|
||||
sleep INTEGER, cook INTEGER, recover INTEGER, hystpreset INTEGER);");
|
||||
Exec("CREATE INDEX IF NOT EXISTS ix_sample_site_time ON sample(site, time);");
|
||||
Exec(@"CREATE TABLE IF NOT EXISTS room_sample(
|
||||
sample_id INTEGER NOT NULL, room_idx INTEGER NOT NULL,
|
||||
damper_sa INTEGER, damper_ra INTEGER,
|
||||
co2 INTEGER, pm25 INTEGER, pm10 INTEGER, voc INTEGER,
|
||||
temp INTEGER, humi INTEGER, led INTEGER, level INTEGER,
|
||||
PRIMARY KEY(sample_id, room_idx));");
|
||||
}
|
||||
|
||||
void Exec(string sql)
|
||||
{
|
||||
using var cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// STATUS 1건 저장. time 은 로컬(날짜 버킷팅 일치).
|
||||
public void Insert(string site, StatusRecord s)
|
||||
{
|
||||
// 자동운전 세부 : 0 비자동 / 1 자동-집중 / 2 자동-분산
|
||||
int automode = s.RunMode == 2 ? (s.AutoState == 1 ? 1 : 2) : 0;
|
||||
int sleep = (s.SubMode & 0x01) != 0 ? 1 : 0;
|
||||
int cook = (s.SubMode & 0x02) != 0 ? 1 : 0;
|
||||
int recov = (s.SubMode & 0x04) != 0 ? 1 : 0;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
using var tx = _conn.BeginTransaction();
|
||||
long id;
|
||||
using (var cmd = _conn.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText =
|
||||
"INSERT INTO sample(site,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset) " +
|
||||
"VALUES($st,$t,$p,$rm,$am,$fm,$sl,$ck,$rc,$hp); SELECT last_insert_rowid();";
|
||||
cmd.Parameters.AddWithValue("$st", site);
|
||||
cmd.Parameters.AddWithValue("$t", DateTime.Now.Ticks);
|
||||
cmd.Parameters.AddWithValue("$p", s.Power);
|
||||
cmd.Parameters.AddWithValue("$rm", s.RunMode);
|
||||
cmd.Parameters.AddWithValue("$am", automode);
|
||||
cmd.Parameters.AddWithValue("$fm", s.FanMode);
|
||||
cmd.Parameters.AddWithValue("$sl", sleep);
|
||||
cmd.Parameters.AddWithValue("$ck", cook);
|
||||
cmd.Parameters.AddWithValue("$rc", recov);
|
||||
cmd.Parameters.AddWithValue("$hp", s.HystPreset);
|
||||
id = (long)(cmd.ExecuteScalar() ?? 0L);
|
||||
}
|
||||
using (var cmd = _conn.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText =
|
||||
"INSERT INTO room_sample(sample_id,room_idx,damper_sa,damper_ra,co2,pm25,pm10,voc,temp,humi,led,level) " +
|
||||
"VALUES($id,$ri,$dsa,$dra,$co2,$pm25,$pm10,$voc,$tp,$hm,$led,$lv);";
|
||||
var pId=cmd.Parameters.Add("$id",SqliteType.Integer); var pRi=cmd.Parameters.Add("$ri",SqliteType.Integer);
|
||||
var pDsa=cmd.Parameters.Add("$dsa",SqliteType.Integer); var pDra=cmd.Parameters.Add("$dra",SqliteType.Integer);
|
||||
var pCo2=cmd.Parameters.Add("$co2",SqliteType.Integer); var pP25=cmd.Parameters.Add("$pm25",SqliteType.Integer);
|
||||
var pP10=cmd.Parameters.Add("$pm10",SqliteType.Integer); var pVoc=cmd.Parameters.Add("$voc",SqliteType.Integer);
|
||||
var pTp=cmd.Parameters.Add("$tp",SqliteType.Integer); var pHm=cmd.Parameters.Add("$hm",SqliteType.Integer);
|
||||
var pLed=cmd.Parameters.Add("$led",SqliteType.Integer); var pLv=cmd.Parameters.Add("$lv",SqliteType.Integer);
|
||||
pId.Value = id;
|
||||
for (int i = 0; i < 4 && i < s.Rooms.Length; i++)
|
||||
{
|
||||
var rm = s.Rooms[i];
|
||||
pRi.Value=i; pDsa.Value=rm.DamperSa?1:0; pDra.Value=rm.DamperEa?1:0;
|
||||
pCo2.Value=rm.Co2; pP25.Value=rm.Pm25; pP10.Value=rm.Pm10; pVoc.Value=rm.Voc;
|
||||
pTp.Value=rm.Temp; pHm.Value=rm.Humi; pLed.Value=rm.LedDim; pLv.Value=rm.LoadScore;
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
tx.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
// 지정 현장·날짜(로컬 00:00~24:00) 하루치를 JSON 배열로 반환.
|
||||
public string LoadDayJson(string site, DateTime day)
|
||||
{
|
||||
long start = day.Date.Ticks, end = day.Date.AddDays(1).Ticks;
|
||||
lock (_lock)
|
||||
{
|
||||
// sample 먼저 (id 순)
|
||||
var ids = new List<long>();
|
||||
var rows = new List<string>(); // 각 sample 의 글로벌 필드 JSON 조각(rooms 채우기 전)
|
||||
var idToIdx = new Dictionary<long, int>();
|
||||
using (var cmd = _conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"SELECT id,time,power,runmode,automode,fanmode,sleep,cook,recover,hystpreset " +
|
||||
"FROM sample WHERE site=$s AND time>=$a AND time<$b ORDER BY id ASC;";
|
||||
cmd.Parameters.AddWithValue("$s", site);
|
||||
cmd.Parameters.AddWithValue("$a", start);
|
||||
cmd.Parameters.AddWithValue("$b", end);
|
||||
using var rd = cmd.ExecuteReader();
|
||||
while (rd.Read())
|
||||
{
|
||||
long id = rd.GetInt64(0);
|
||||
var t = new DateTime(rd.GetInt64(1));
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("{\"t\":\"").Append(t.ToString("HH:mm:ss", CultureInfo.InvariantCulture)).Append('"')
|
||||
.Append(",\"date\":\"").Append(t.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append('"')
|
||||
.Append(",\"power\":").Append(rd.GetInt32(2))
|
||||
.Append(",\"run_mode\":").Append(rd.GetInt32(3))
|
||||
.Append(",\"auto_mode\":").Append(rd.GetInt32(4))
|
||||
.Append(",\"fan_mode\":").Append(rd.GetInt32(5))
|
||||
.Append(",\"sleep\":").Append(rd.GetInt32(6))
|
||||
.Append(",\"cook\":").Append(rd.GetInt32(7))
|
||||
.Append(",\"recover\":").Append(rd.GetInt32(8))
|
||||
.Append(",\"hyst_preset\":").Append(rd.GetInt32(9))
|
||||
.Append(",\"rooms\":[");
|
||||
idToIdx[id] = rows.Count;
|
||||
rows.Add(sb.ToString()); // 아직 rooms 미완 — 아래서 이어붙임
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
if (ids.Count == 0) return "[]";
|
||||
|
||||
// room_sample 을 sample 별로 모음
|
||||
var roomsById = new Dictionary<long, List<string>>();
|
||||
using (var cmd = _conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"SELECT sample_id,damper_sa,damper_ra,co2,pm25,pm10,voc,temp,humi,led,level " +
|
||||
"FROM room_sample WHERE sample_id BETWEEN $min AND $max ORDER BY sample_id, room_idx;";
|
||||
cmd.Parameters.AddWithValue("$min", ids[0]);
|
||||
cmd.Parameters.AddWithValue("$max", ids[ids.Count - 1]);
|
||||
using var rd = cmd.ExecuteReader();
|
||||
while (rd.Read())
|
||||
{
|
||||
long sid = rd.GetInt64(0);
|
||||
if (!idToIdx.ContainsKey(sid)) continue;
|
||||
if (!roomsById.TryGetValue(sid, out var lst)) { lst = new List<string>(); roomsById[sid] = lst; }
|
||||
lst.Add("{\"damper_sa\":" + rd.GetInt32(1) + ",\"damper_ra\":" + rd.GetInt32(2) +
|
||||
",\"co2\":" + rd.GetInt32(3) + ",\"pm25\":" + rd.GetInt32(4) + ",\"pm10\":" + rd.GetInt32(5) +
|
||||
",\"voc\":" + rd.GetInt32(6) + ",\"temp\":" + rd.GetInt32(7) + ",\"humi\":" + rd.GetInt32(8) +
|
||||
",\"led\":" + rd.GetInt32(9) + ",\"level\":" + rd.GetInt32(10) + "}");
|
||||
}
|
||||
}
|
||||
|
||||
var outSb = new StringBuilder("[");
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
if (i > 0) outSb.Append(',');
|
||||
outSb.Append(rows[i]);
|
||||
if (roomsById.TryGetValue(ids[i], out var lst)) outSb.Append(string.Join(",", lst));
|
||||
outSb.Append("]}");
|
||||
}
|
||||
outSb.Append(']');
|
||||
return outSb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터가 있는 날짜 목록(JSON 배열, 오름차순).
|
||||
public string LoadDatesJson(string site)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var list = new List<string>();
|
||||
using var cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT DISTINCT time/864000000000 FROM sample WHERE site=$s ORDER BY 1;";
|
||||
cmd.Parameters.AddWithValue("$s", site);
|
||||
using var rd = cmd.ExecuteReader();
|
||||
while (rd.Read())
|
||||
{
|
||||
var d = new DateTime(rd.GetInt64(0) * TimeSpan.TicksPerDay);
|
||||
list.Add("\"" + d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + "\"");
|
||||
}
|
||||
return "[" + string.Join(",", list) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _conn.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using ErvProtocol;
|
||||
|
||||
namespace ErvCollector.Storage
|
||||
{
|
||||
// InfluxDB v2 라인 프로토콜 기록 (외부 라이브러리 없이 HTTP /api/v2/write)
|
||||
//
|
||||
// measurement:
|
||||
// erv_global,site=<site> power,run_mode,auto_state,fan_mode,sub_mode,hood,
|
||||
// hyst_preset,hyst_pm25,hyst_pm10,hyst_voc,hyst_co2,error_code
|
||||
// erv_room,site=<site>,room=<1..4> damper,pm25,pm10,voc,co2,air_quality,led_dim,
|
||||
// load_score,final_volume
|
||||
public sealed class InfluxLineWriter : IDisposable
|
||||
{
|
||||
readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(10) };
|
||||
readonly string _writeUrl;
|
||||
|
||||
public event Action<string>? OnError;
|
||||
|
||||
public InfluxLineWriter(string url, string org, string bucket, string token)
|
||||
{
|
||||
_writeUrl = $"{url.TrimEnd('/')}/api/v2/write" +
|
||||
$"?org={Uri.EscapeDataString(org)}&bucket={Uri.EscapeDataString(bucket)}&precision=ns";
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", token);
|
||||
}
|
||||
|
||||
public async Task WriteAsync(string site, StatusRecord s, DateTime nowUtc)
|
||||
{
|
||||
long ts = ToUnixNanos(nowUtc);
|
||||
var sb = new StringBuilder(512);
|
||||
|
||||
// erv_global
|
||||
sb.Append("erv_global,site=").Append(site).Append(' ')
|
||||
.Append("power=").Append(s.Power).Append("i,")
|
||||
.Append("run_mode=").Append(s.RunMode).Append("i,")
|
||||
.Append("auto_state=").Append(s.AutoState).Append("i,")
|
||||
.Append("fan_mode=").Append(s.FanMode).Append("i,")
|
||||
.Append("sub_mode=").Append(s.SubMode).Append("i,")
|
||||
.Append("hood=").Append(s.Hood).Append("i,")
|
||||
.Append("hyst_preset=").Append(s.HystPreset).Append("i,")
|
||||
.Append("hyst_pm25=").Append(s.HystPm25).Append("i,")
|
||||
.Append("hyst_pm10=").Append(s.HystPm10).Append("i,")
|
||||
.Append("hyst_voc=").Append(s.HystVoc).Append("i,")
|
||||
.Append("hyst_co2=").Append(s.HystCo2).Append("i,")
|
||||
.Append("error_code=").Append(s.ErrorCode).Append("i,")
|
||||
.Append("reset=").Append(s.Reset).Append('i')
|
||||
.Append(' ').Append(ts).Append('\n');
|
||||
|
||||
// erv_room (4실)
|
||||
for (int r = 0; r < 4; r++)
|
||||
{
|
||||
var rm = s.Rooms[r];
|
||||
sb.Append("erv_room,site=").Append(site).Append(",room=").Append(r + 1).Append(' ')
|
||||
.Append("damper=").Append(rm.Damper).Append("i,")
|
||||
.Append("pm25=").Append(rm.Pm25).Append("i,")
|
||||
.Append("pm10=").Append(rm.Pm10).Append("i,")
|
||||
.Append("voc=").Append(rm.Voc).Append("i,")
|
||||
.Append("co2=").Append(rm.Co2).Append("i,")
|
||||
.Append("air_quality=").Append(rm.AirQuality).Append("i,")
|
||||
.Append("led_dim=").Append(rm.LedDim).Append("i,")
|
||||
.Append("load_score=").Append(rm.LoadScore).Append("i,")
|
||||
.Append("final_volume=").Append(rm.FinalVolume).Append('i')
|
||||
.Append(' ').Append(ts).Append('\n');
|
||||
}
|
||||
|
||||
// erv_vsp (9엔트리: 환기1~4, 바이패스, 공청1~4)
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
var v = s.Vsp[i];
|
||||
sb.Append("erv_vsp,site=").Append(site).Append(",vsp=").Append(VspInfo.Keys[i]).Append(' ')
|
||||
.Append("sa=").Append(v.Sa).Append("i,")
|
||||
.Append("ea=").Append(v.Ea).Append('i')
|
||||
.Append(' ').Append(ts).Append('\n');
|
||||
}
|
||||
|
||||
// erv_hyst (프리셋별 임계값: eco/normal/turbo)
|
||||
string[] presetKey = { "eco", "normal", "turbo" };
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var h = s.HystTable[i];
|
||||
sb.Append("erv_hyst,site=").Append(site).Append(",preset=").Append(presetKey[i]).Append(' ')
|
||||
.Append("pm25=").Append(h.Pm25).Append("i,")
|
||||
.Append("pm10=").Append(h.Pm10).Append("i,")
|
||||
.Append("voc=").Append(h.Voc).Append("i,")
|
||||
.Append("co2=").Append(h.Co2).Append('i')
|
||||
.Append(' ').Append(ts).Append('\n');
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(sb.ToString(), Encoding.UTF8);
|
||||
using var resp = await _http.PostAsync(_writeUrl, content);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await resp.Content.ReadAsStringAsync();
|
||||
OnError?.Invoke($"Influx write HTTP {(int)resp.StatusCode}: {body}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnError?.Invoke($"Influx write FAIL: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static long ToUnixNanos(DateTime utc)
|
||||
{
|
||||
long ticks = utc.ToUniversalTime().Ticks - DateTime.UnixEpoch.Ticks; // 100ns ticks
|
||||
return ticks * 100L;
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using ErvProtocol;
|
||||
|
||||
namespace ErvCollector.Storage
|
||||
{
|
||||
// 저장 정책: "N초 주기 + 상태(이산값) 변화 시 즉시"
|
||||
// - 연속값(센서 PM/VOC/CO2 등)은 주기 샘플
|
||||
// - 이산값(전원/모드/팬/부가/후드/프리셋/에러 + 각실 댐퍼/공기질등급/LED)이 바뀌면 즉시 기록
|
||||
public sealed class Sampler
|
||||
{
|
||||
readonly TimeSpan _interval;
|
||||
string? _lastFingerprint;
|
||||
DateTime _lastWriteUtc = DateTime.MinValue;
|
||||
|
||||
public Sampler(int intervalSeconds)
|
||||
{
|
||||
_interval = TimeSpan.FromSeconds(intervalSeconds <= 0 ? 10 : intervalSeconds);
|
||||
}
|
||||
|
||||
// 기록해야 하면 true (그리고 내부 상태 갱신)
|
||||
public bool ShouldWrite(StatusRecord s, DateTime nowUtc, out string reason)
|
||||
{
|
||||
string fp = Fingerprint(s);
|
||||
bool changed = fp != _lastFingerprint;
|
||||
bool periodic = (nowUtc - _lastWriteUtc) >= _interval;
|
||||
|
||||
if (changed || periodic)
|
||||
{
|
||||
reason = changed ? (_lastFingerprint == null ? "first" : "change") : "periodic";
|
||||
_lastFingerprint = fp;
|
||||
_lastWriteUtc = nowUtc;
|
||||
return true;
|
||||
}
|
||||
reason = "skip";
|
||||
return false;
|
||||
}
|
||||
|
||||
static string Fingerprint(StatusRecord s)
|
||||
{
|
||||
// 이산 상태/제어값만 모아 변화 감지 (연속 센서값 제외)
|
||||
var sb = new System.Text.StringBuilder(48);
|
||||
sb.Append(s.Power).Append(s.RunMode).Append('.').Append(s.AutoState).Append('.')
|
||||
.Append(s.FanMode).Append('.').Append(s.SubMode).Append('.').Append(s.Hood).Append('.')
|
||||
.Append(s.HystPreset).Append('.').Append(s.ErrorCode).Append('.').Append(s.Reset);
|
||||
foreach (var r in s.Rooms)
|
||||
sb.Append('|').Append(r.Damper).Append(r.AirQuality).Append(r.LedDim);
|
||||
// VSP(설정값) 변경도 즉시 기록
|
||||
foreach (var v in s.Vsp)
|
||||
sb.Append('#').Append(v.Sa).Append(',').Append(v.Ea);
|
||||
// 히스테리시스 프리셋 테이블 변경도 즉시 기록
|
||||
foreach (var h in s.HystTable)
|
||||
sb.Append('@').Append(h.Pm25).Append(',').Append(h.Pm10).Append(',').Append(h.Voc).Append(',').Append(h.Co2);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Sockets;
|
||||
using ErvProtocol;
|
||||
|
||||
namespace ErvCollector.Storage
|
||||
{
|
||||
// 현장별 활성 소켓 + 최신 상태 보관. 제어 프레임을 해당 현장 EW11 소켓으로 송신.
|
||||
public sealed class SiteHub
|
||||
{
|
||||
readonly ConcurrentDictionary<string, NetworkStream> _sockets = new();
|
||||
readonly ConcurrentDictionary<string, StatusRecord> _last = new();
|
||||
readonly ConcurrentDictionary<string, DateTime> _lastSeenUtc = new();
|
||||
|
||||
public event Action<string>? Log;
|
||||
|
||||
public void SetSocket(string site, NetworkStream s) { _sockets[site] = s; }
|
||||
public void RemoveSocket(string site, NetworkStream s)
|
||||
{
|
||||
// 현재 등록된 것이 이 스트림일 때만 제거(재연결 레이스 방지)
|
||||
if (_sockets.TryGetValue(site, out var cur) && ReferenceEquals(cur, s))
|
||||
_sockets.TryRemove(site, out _);
|
||||
}
|
||||
|
||||
public void SetStatus(string site, StatusRecord rec)
|
||||
{
|
||||
_last[site] = rec;
|
||||
_lastSeenUtc[site] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public StatusRecord? GetStatus(string site) => _last.TryGetValue(site, out var r) ? r : null;
|
||||
public DateTime LastSeen(string site) => _lastSeenUtc.TryGetValue(site, out var t) ? t : DateTime.MinValue;
|
||||
public bool IsOnline(string site) =>
|
||||
_sockets.ContainsKey(site) && (DateTime.UtcNow - LastSeen(site)) < TimeSpan.FromSeconds(30);
|
||||
|
||||
// 제어 프레임 송신. 성공 true.
|
||||
public bool TrySend(string site, byte[] frame)
|
||||
{
|
||||
if (!_sockets.TryGetValue(site, out var stream))
|
||||
{
|
||||
Log?.Invoke($"[{site}] 제어 실패: 연결 없음");
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
lock (stream) { stream.Write(frame, 0, frame.Length); stream.Flush(); }
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log?.Invoke($"[{site}] 제어 송신 오류: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user