- refactor: DabitChe.Mcp → Dabit.Mcp (namespace, csproj, exe 모두) - fix: display_message colors를 쉼표 구분 문자열로 변경 (ModelContextProtocol 1.3.0 배열 바인딩 버그 우회) - docs: scroll 4방향(left/right/up/down) 실기 검증 완료 (DIBD600T V3.6.1) 20 도구 (읽기 4 + 콘텐츠 2 + 설정 14), stdio 전송, Windows exe. 실기 검증: scroll/fill_screen 8색/display_message 글자별색 모두 정상. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
8.5 KiB
C#
164 lines
8.5 KiB
C#
using System.ComponentModel;
|
|
using System.Text;
|
|
using Dabit.Mcp.Services;
|
|
using Microsoft.Extensions.Logging;
|
|
using ModelContextProtocol.Server;
|
|
|
|
namespace Dabit.Mcp.Tools;
|
|
|
|
/// <summary>
|
|
/// 전광판 표시를 바꾸는 도구. ⚠️ 쓰기 — 실제 장비의 표시 내용을 변경한다.
|
|
/// </summary>
|
|
[McpServerToolType]
|
|
public sealed class DisplayControlTools
|
|
{
|
|
private readonly LedControlService _led;
|
|
private readonly ILogger<DisplayControlTools> _log;
|
|
|
|
public DisplayControlTools(LedControlService led, ILogger<DisplayControlTools> log)
|
|
{
|
|
_led = led;
|
|
_log = log;
|
|
}
|
|
|
|
/// <summary>color 문자열 → ASCII /C 색상 코드(0~7). 8색 고정(정본 ascii-reference).</summary>
|
|
private static readonly Dictionary<string, string> ColorCodes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["black"] = "0", ["red"] = "1", ["green"] = "2", ["yellow"] = "3",
|
|
["blue"] = "4", ["magenta"] = "5", ["cyan"] = "6", ["white"] = "7",
|
|
};
|
|
|
|
/// <summary>
|
|
/// effect 문자열 → /E 효과 코드(2자리). 입장·퇴장에 각각 적용한다(/E{입장}{퇴장}).
|
|
/// stop(01)·scroll 4방향(06~09) 모두 실기 검증됨(2026-06-02, DIBD600T 펌웨어 V3.6.1, 육안 확인).
|
|
/// </summary>
|
|
private static readonly Dictionary<string, string> 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<string> 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<string> 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)");
|
|
}
|
|
|
|
/// <summary>effect 문자열 → /E 코드. 모르는 값은 정지(01)로.</summary>
|
|
private string ResolveEffect(string name)
|
|
{
|
|
if (EffectCodes.TryGetValue(name.Trim(), out var c))
|
|
return c;
|
|
_log.LogWarning("알 수 없는 효과 '{Effect}' → stop", name);
|
|
return "01";
|
|
}
|
|
|
|
/// <summary>본문에 ASCII 속성/프레임 제어코드가 섞이면 표시가 깨지거나 속성이 주입된다. 차단.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>응답을 사용자 메시지로 변환. 성공 판정은 Result=="0" allowlist.</summary>
|
|
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}\") — 실기 확인이 필요합니다.",
|
|
};
|
|
}
|
|
}
|