using System.ComponentModel; using System.Text; using Dabit.Mcp.Services; using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; namespace Dabit.Mcp.Tools; /// /// 전광판 표시를 바꾸는 도구. ⚠️ 쓰기 — 실제 장비의 표시 내용을 변경한다. /// [McpServerToolType] public sealed class DisplayControlTools { private readonly LedControlService _led; private readonly ILogger _log; public DisplayControlTools(LedControlService led, ILogger log) { _led = led; _log = log; } /// color 문자열 → ASCII /C 색상 코드(0~7). 8색 고정(정본 ascii-reference). private static readonly Dictionary ColorCodes = new(StringComparer.OrdinalIgnoreCase) { ["black"] = "0", ["red"] = "1", ["green"] = "2", ["yellow"] = "3", ["blue"] = "4", ["magenta"] = "5", ["cyan"] = "6", ["white"] = "7", }; /// /// effect 문자열 → /E 효과 코드(2자리). 입장·퇴장에 각각 적용한다(/E{입장}{퇴장}). /// stop(01)·scroll 4방향(06~09) 모두 실기 검증됨(2026-06-02, DIBD600T 펌웨어 V3.6.1, 육안 확인). /// private static readonly Dictionary EffectCodes = new(StringComparer.OrdinalIgnoreCase) { ["stop"] = "01", // 정지 — 검증 ["scroll_left"] = "06", // 좌이동(오른쪽→왼쪽) — 검증 ["scroll_right"] = "07", // 우이동(왼쪽→오른쪽) — 검증(2026-06-02) ["scroll_up"] = "08", // 상향(아래→위) — 검증(2026-06-02) ["scroll_down"] = "09", // 하향(위→아래) — 검증(2026-06-02) }; private const int MaxTextLength = 100; [McpServerTool] [Description("전광판에 단색 텍스트 한 줄을 즉시 표시한다(실시간 메시지, 정지). " + "⚠️ 쓰기 동작 — 현재 표시 중인 실시간 메시지를 즉시 덮어쓴다. " + "지원 색상 8종: red, green, yellow, blue, magenta, cyan, white, black (그 외는 노랑으로 근사). " + "글자별 색이나 이동 효과가 필요하면 display_message를 쓴다.")] public async Task DisplayText( [Description("표시할 텍스트(한글 가능, 최대 100자). 예: \"환영합니다\"")] string text, [Description("문자 색상. red/green/yellow/blue/magenta/cyan/white/black 중 하나. 기본 yellow")] string color = "yellow", CancellationToken ct = default) { if (string.IsNullOrEmpty(text)) return "표시할 텍스트가 비어 있습니다."; if (!ValidateText(text, out var err)) return err; if (!ColorCodes.TryGetValue(color.Trim(), out var code)) { _log.LogWarning("지원하지 않는 색상 '{Color}' → 노랑(yellow)으로 근사", color); code = "3"; } // 실시간 메시지(msgType "0") + 색상/정지(즉시)/속도유지/EUC-KR 16x16. (실기 검증된 형식) var data = $"/C{code}/E0101/S2002/F0003{text}"; _log.LogInformation("display_text: \"{Text}\" color={Color}(/C{Code})", text, color, code); return Format(await _led.SendAsciiAsync("0", data, ct), $"\"{text}\" ({color})"); } [McpServerTool] [Description("전광판에 텍스트를 글자별 색상·입장/퇴장 이동효과·속도와 함께 표시한다(실시간 메시지). " + "⚠️ 쓰기 동작 — 현재 표시 중인 실시간 메시지를 즉시 덮어쓴다. " + "colors: 글자별 색을 쉼표로 구분(text 글자 수와 1:1, 예 \"red,green,yellow,blue\", 생략 시 전체 노랑). " + "effect=입장 효과, exit_effect=퇴장 효과: stop/scroll_left/scroll_right/scroll_up/scroll_down. " + "연속으로 흐르게 하려면 입장·퇴장을 같은 scroll로 준다(예: 둘 다 scroll_left). " + "speed 0~99(작을수록 빠름, 0=최고속). hold_sec 유지 시간(초). " + "scroll 4방향(left/right/up/down) 모두 실기 검증 완료(2026-06-02, DIBD600T V3.6.1).")] public async Task DisplayMessage( [Description("표시할 텍스트(한글 가능). 예: \"가나다라\"")] string text, [Description("글자별 색을 쉼표로 구분한 문자열, text 글자와 1:1 대응. 예: \"red,green,yellow,blue\". 생략 시 전체 노랑")] string? colors = null, [Description("입장 효과: stop/scroll_left/scroll_right/scroll_up/scroll_down. 기본 stop")] string effect = "stop", [Description("퇴장 효과: 위와 동일 목록. 입장과 같은 scroll을 주면 멈춤 없이 연속으로 흐른다. 기본 stop")] string exitEffect = "stop", [Description("이동 속도 0~99(작을수록 빠름, 0=최고속). 기본 20")] int speed = 20, [Description("유지 시간(초), 0=대기 없음. 기본 1")] int holdSec = 1, CancellationToken ct = default) { if (string.IsNullOrEmpty(text)) return "표시할 텍스트가 비어 있습니다."; if (!ValidateText(text, out var err)) return err; var enter = ResolveEffect(effect); var exit = ResolveEffect(exitEffect); var sp = Math.Clamp(speed, 0, 99); var hold2 = Math.Clamp(holdSec * 2, 0, 99); // 유지시간은 0.5초 단위(1초=02) // colors는 쉼표 구분 문자열("red,green,..."). ModelContextProtocol 1.3.0이 string[]? 파라미터의 // JSON 스키마를 깨뜨려(type 누락) 배열 인자 바인딩 시 호출 자체가 실패하므로, 문자열로 받아 직접 분리한다. var colorArr = string.IsNullOrWhiteSpace(colors) ? null : colors.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); // 속성(입장/퇴장 효과·속도·폰트) 먼저, 그 뒤에 글자별 색을 인라인으로 붙인다. var sb = new StringBuilder($"/E{enter}{exit}/S{sp:D2}{hold2:D2}/F0003"); for (int i = 0; i < text.Length; i++) { var code = "3"; // 기본 노랑 var want = colorArr is not null && i < colorArr.Length ? colorArr[i] : null; if (want is not null && ColorCodes.TryGetValue(want.Trim(), out var cc)) code = cc; else if (want is not null) _log.LogWarning("지원하지 않는 색상 '{Color}'(글자 {Idx}) → 노랑", want, i); sb.Append($"/C{code}{text[i]}"); } var data = sb.ToString(); _log.LogInformation("display_message: 입장={Enter} 퇴장={Exit} speed={Speed} hold={Hold}s data=\"{Data}\"", effect, exitEffect, sp, holdSec, data); return Format(await _led.SendAsciiAsync("0", data, ct), $"\"{text}\" (입장 {effect}, 퇴장 {exitEffect}, 속도 {sp}, 유지 {holdSec}s)"); } /// effect 문자열 → /E 코드. 모르는 값은 정지(01)로. private string ResolveEffect(string name) { if (EffectCodes.TryGetValue(name.Trim(), out var c)) return c; _log.LogWarning("알 수 없는 효과 '{Effect}' → stop", name); return "01"; } /// 본문에 ASCII 속성/프레임 제어코드가 섞이면 표시가 깨지거나 속성이 주입된다. 차단. private bool ValidateText(string text, out string error) { if (text.Contains('/') || text.Contains("![") || text.Contains("!]")) { error = "텍스트에 제어 문자('/', '![', '!]')는 사용할 수 없습니다 — 속성 코드로 해석되어 표시가 깨집니다."; return false; } if (text.Length > MaxTextLength) { error = $"텍스트가 너무 깁니다(최대 {MaxTextLength}자, 입력 {text.Length}자)."; return false; } error = ""; return true; } /// 응답을 사용자 메시지로 변환. 성공 판정은 Result=="0" allowlist. private static string Format(DibdProtocol.Protocol.AsciiParsedResponse? resp, string what) { if (resp is null) return "전송 실패 — 연결 또는 통신 오류 (전광판 응답 없음)."; return resp.Result switch { "0" => $"표시 완료: {what}.", "F" => "전광판이 명령을 거부했습니다 (Result=F).", _ => $"전송했으나 응답 형식이 예상과 다릅니다 (Result=\"{resp.Result}\") — 실기 확인이 필요합니다.", }; } }