diff --git a/README.md b/README.md index 97db155..e531710 100644 --- a/README.md +++ b/README.md @@ -66,15 +66,15 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 已完成: - **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。 -- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox,教材控件库的 Lamp、Alarm。 -- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1 和 3、项目 5 任务 1–3,共 12 个。网址带 `?sample=示例编号` 可直接打开指定示例。 +- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox、PictureBox,教材控件库的 Lamp、Alarm。 +- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1–3、项目 5 任务 1–3,共 13 个。网址带 `?sample=示例编号` 可直接打开指定示例。 - 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 - 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。 - 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。 -- 第 3 阶段(进行中):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`)。 +- 第 3 阶段(进行中):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。 尚未完成: -- 教材项目 4 任务 2 和项目 6–8(需要摄像头、数据库、TrackBar、PictureBox、图表)。 +- 教材项目 6–8(需要数据库、TrackBar、资源图片、图表、虚拟时钟)。 diff --git a/docs/设计方案.md b/docs/设计方案.md index 7fd7958..deb10fb 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -210,7 +210,7 @@ IOT/ | 1 C# 入门基础 | 登录与新增用户(两个窗体)、计算器、继承与多态演示 | 多窗体、Label、TextBox(PasswordChar)、Button、ComboBox、MessageBox、控制台输出 | 已完成 | | 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 | | 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 | -| 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 任务 1、3 已完成;任务 2 需摄像头 | +| 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 已完成 | | 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 | | 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | | | 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | | @@ -236,7 +236,7 @@ flowchart TB P0["阶段 0 · 技术验证(已完成 2026-10-22)
教材项目 2 任务 1 原样运行;实测编译、延迟、内存"] P1["阶段 1 · MVP(进行中,约 3 个月)
虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔
教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔
部署(沙箱、Docker Compose、GitHub Actions)✔"] P2["阶段 2 · 教学闭环(进行中,约 2 个月)
账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔
评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"] - P3["阶段 3 · 扩展(进行中)
教材项目 4–8:摄像头、线程 ✔、SQLite 模拟数据库、图表、虚拟时钟"] + P3["阶段 3 · 扩展(进行中)
教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库、图表、虚拟时钟"] P4["阶段 4 · 进阶(之后)
校园街景场景、故障注入题库、导出 VS 工程"] P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1 P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2 @@ -280,6 +280,8 @@ flowchart TB - 2026-10-22:项目 5 补齐(线程、ListBox)。旧版线程 API:编译时把 `thd.Suspend()` / `Resume()` / `Abort()`、`thd.ThreadState`、`Thread.Sleep`、`new Thread(...)` 改写成平台实现(只改语义上确实是 `System.Threading.Thread` 的调用,不换行,报错和异常的行号不变;编辑器里看到的仍是学生的原始代码,也不再提示"已过时")。新版 .NET 不能从外部停住线程,平台的实现是协作式的:被挂起或终止的线程在下一个检查点停下或结束(`Thread.Sleep`、从后台线程改控件属性、调用设备类库、弹出提示框),教材的线程方法每次循环都经过这些地方;休眠中被终止的线程立即醒来结束。行为与 .NET Framework 一致的地方:线程可以挂起自己、对没启动的线程 Abort 不报错、`Resume` 没挂起的线程和直接 `Abort` 挂起的线程抛出 ThreadStateException(书上任务 2 关闭窗体时直接 `thd.Abort()`,在真实环境中也会出这个错,参考答案改为先 `Resume` 再 `Abort`,报错信息里写明这样改)。ListBox:列表项、单选、SelectedIndexChanged 先于 Click、Text 是选中项、删除选中项后取消选中;设计器工具箱加"列表框";评分规则的"包含"对列表框检查所有列表项。新增示例:项目 5 任务 2(烟雾,线程)、任务 3(推送灾情,二维数组、两个窗体、ListBox),各有评分规则和端到端测试,共 12 个示例。 +- 2026-10-22:项目 4 任务 2 网络摄像机,至此教材项目 1–5 全部完成(13 个示例)。摄像头画面是自绘的扁平插画(`runtime/Sim.Devices/Camera/scene.svg`,从校园里望向东围墙,墙头装着红外对射),按云台位置(左右 7 档、上下 3 档)切成 640×480 的 JPEG 帧;`PanUp` 等每次转一格,实训台红外对射被遮挡时画面里出现翻墙的人、光束断开,为书上"发现入侵自动抓拍"的探究题准备。`ipCameraHelper` 的类名、委托名(`CameraImage`)、事件参数(`ImageReadEvenArgs.img`)与教材 dll 一致,和仿真版一样每 250 ms 在后台线程上触发 `ImageRead`,书上不用 Invoke 直接给 `pictureBox1.Image` 赋值也能显示。`Image` / `Bitmap` 保存真实的图片数据:`Image.Save` 写出能打开的 jpg,`FromFile` / `FromStream` 读回,文件夹不存在时和 GDI+ 一样报"一般性错误"。图片数据每张只发给浏览器一次(按内容哈希缓存),之后控件属性里只带编号;同一位置的画面复用同一个对象,画面不变时不产生消息。学生程序的"程序所在目录"(`AppDomain.CurrentDomain.BaseDirectory`,编译检查单独放行)和当前目录都指向可写的 bin/Debug(沙箱里在沙箱自己的临时目录),程序结束后清掉;`Image.Save` 保存的图片显示在运行区的照片列表里,点开看大图,下次运行时清空。新增 PictureBox(SizeMode 五种、BorderStyle),设计器工具箱加"图片框";评分规则加"图片框显示了图片""摄像头开启 / 关闭 / 朝某方向转过""保存了图片"三种检查。 + **其他改进**: - 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。 @@ -296,7 +298,7 @@ flowchart TB | 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 | | T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 用 ScriptDom 解析后转写;连接串一律指向学生自己的 SQLite 库;日期统一存为标准格式;以教材全部 SQL 语句为测试集 | | 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 | -| 图片处理 | System.Drawing 在 Linux 上不受支持 | 自实现 `Image` / `Bitmap` 等少量类型,只传递和保存图片 | +| 图片处理 | System.Drawing 在 Linux 上不受支持 | 已实现:自己的 `Image` / `Bitmap` 只保存图片文件数据和尺寸,能显示、保存、读回,不能逐像素绘制 | | 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 | | 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms | | 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 | diff --git a/runtime/Sim.Devices/Camera/build-frames.ts b/runtime/Sim.Devices/Camera/build-frames.ts new file mode 100644 index 0000000..7038c32 --- /dev/null +++ b/runtime/Sim.Devices/Camera/build-frames.ts @@ -0,0 +1,48 @@ +// 生成网络摄像机的画面帧:把 scene.svg 渲染成全景图(有人翻墙 / 没有人两种),按云台位置切成 640×480 的 JPEG。 +// 帧文件提交在仓库里,改了 scene.svg 后重新运行:bun runtime/Sim.Devices/Camera/build-frames.ts +// 需要 rsvg-convert(librsvg)和 magick(ImageMagick)。 +import { mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +/** 与 IPCameraHelper.cs 中的常量一致 */ +const VIEW = { width: 640, height: 480 } +const STEPS = { x: 7, y: 3 } +const STRIDE = { x: 200, y: 160 } + +const dir = import.meta.dir +const framesDir = join(dir, 'frames') +const work = join(tmpdir(), `camera-frames-${process.pid}`) + +async function run(cmd: string[]) { + const proc = Bun.spawn(cmd, { stderr: 'inherit' }) + if ((await proc.exited) !== 0) throw new Error(`${cmd[0]} 失败`) +} + +const scene = await Bun.file(join(dir, 'scene.svg')).text() +const intruder = scene + .replace('id="intruder" display="none"', 'id="intruder"') + .replace(' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs index cf346b5..5bf39b8 100644 --- a/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs +++ b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs @@ -17,6 +17,7 @@ public class DeviceCtrlHelper public static ADAM4017Helper adam4017Helper = new(); public static ADAM4150Helper adam4150Helper = new(); public static ZigBeeHelper zigBeeHelper = new(); + public static IPCameraHelper ipCameraHelper = new(); } public class SerialPortHelper diff --git a/runtime/Sim.Devices/DeviceCtrlLibrary/IPCameraHelper.cs b/runtime/Sim.Devices/DeviceCtrlLibrary/IPCameraHelper.cs new file mode 100644 index 0000000..3e7dbe9 --- /dev/null +++ b/runtime/Sim.Devices/DeviceCtrlLibrary/IPCameraHelper.cs @@ -0,0 +1,127 @@ +using System; +using System.Drawing; +using System.IO; +using System.Threading; +using Sim.Host; + +namespace DeviceCtrlLibrary; + +/// 摄像头每拍到一帧画面时触发(教材 dll 里的委托名就是 CameraImage) +public delegate void CameraImage(ImageReadEvenArgs e); + +public class ImageEventArgs : EventArgs { } + +/// 摄像头画面事件的参数;类名拼写与教材 dll 一致(EvenArgs) +public class ImageReadEvenArgs +{ + public ImageEventArgs ImageEventArg { get; set; } + public Image img { get; set; } +} + +/// +/// 网络摄像机。画面是自绘的东围墙场景(Camera/scene.svg 切出的帧):Pan 方法每次转动一格, +/// 实训台的红外对射被遮挡时画面里出现翻墙的人。和教材仿真版一样,ImageRead 在后台线程上定时触发。 +/// +public class IPCameraHelper +{ + // 与 Camera/build-frames.ts 一致:左右 7 档、上下 3 档,开始时在正中 + private const int StepsX = 7; + private const int StepsY = 3; + private const int FrameIntervalMs = 250; + + private static readonly string FramesDir = Path.Combine(Path.GetDirectoryName(typeof(IPCameraHelper).Assembly.Location)!, "Camera", "frames"); + private static readonly Image?[] Frames = new Image?[StepsX * StepsY * 2]; + + private readonly object _lock = new(); + private string? _ip; + private Timer? _timer; + private int _x = StepsX / 2; + private int _y = StepsY / 2; + private int _ticking; + private bool _errorReported; + + public event CameraImage ImageRead; + + /// 连接摄像机。任何非空的 IP 都能连上(教材用 admin、空密码) + public void Open(string IpAdderss, string Uid, string Pwd) => _ip = IpAdderss; + + /// 开始传送画面;没有 Open 时不传 + public void Start() + { + if (string.IsNullOrWhiteSpace(_ip)) return; + lock (_lock) + { + _timer ??= new Timer(_ => Tick()); + _timer.Change(0, FrameIntervalMs); + } + SimHost.SendDevice("camera", "on", true); + } + + public void Stop() + { + lock (_lock) _timer?.Change(Timeout.Infinite, Timeout.Infinite); + SimHost.SendDevice("camera", "on", false); + } + + public void Close() + { + Stop(); + _ip = null; + } + + public void PanLeft() => Pan(-1, 0); + public void PanRight() => Pan(1, 0); + public void PanUp() => Pan(0, -1); + public void PanDown() => Pan(0, 1); + + /// 云台转动一格,到头后不再转;没有 Open 时无效 + private void Pan(int dx, int dy) + { + if (string.IsNullOrWhiteSpace(_ip)) return; + int x, y; + lock (_lock) + { + _x = Math.Clamp(_x + dx, 0, StepsX - 1); + _y = Math.Clamp(_y + dy, 0, StepsY - 1); + (x, y) = (_x, _y); + } + SimHost.SendDevice("camera", "pan", new[] { x, y }); + } + + private void Tick() + { + // 上一帧的处理方法还没执行完时跳过这一帧 + if (Interlocked.Exchange(ref _ticking, 1) == 1) return; + try + { + var handler = ImageRead; + if (handler == null) return; + int x, y; + lock (_lock) (x, y) = (_x, _y); + handler(new ImageReadEvenArgs { ImageEventArg = new ImageEventArgs(), img = Frame(x, y, TrainingBoard.ReadDigital(3)) }); + } + catch (Exception ex) + { + // 教材仿真版用 System.Timers.Timer,处理方法里的异常不会让程序退出;这里报告一次,之后照常传画面 + if (!_errorReported) + { + _errorReported = true; + SimHost.ReportException(ex); + } + } + finally + { + Volatile.Write(ref _ticking, 0); + } + } + + /// 同一位置的画面复用同一个 Image 对象:赋给 PictureBox 的是同一张图时不用重新发送 + private static Image Frame(int x, int y, bool intruder) + { + var index = (y * StepsX + x) * 2 + (intruder ? 1 : 0); + lock (Frames) + { + return Frames[index] ??= Image.FromStream(new MemoryStream(File.ReadAllBytes(Path.Combine(FramesDir, $"{x}-{y}-{(intruder ? 1 : 0)}.jpg")))); + } + } +} diff --git a/runtime/Sim.Devices/Sim.Devices.csproj b/runtime/Sim.Devices/Sim.Devices.csproj index c75e217..facac6c 100644 --- a/runtime/Sim.Devices/Sim.Devices.csproj +++ b/runtime/Sim.Devices/Sim.Devices.csproj @@ -6,6 +6,10 @@ DeviceCtrlLibrary $(NoWarn);CS1591;CS8618;IDE1006 + + + + diff --git a/runtime/Sim.Runner/ApiPolicy.cs b/runtime/Sim.Runner/ApiPolicy.cs index 8cb32f6..fcdff20 100644 --- a/runtime/Sim.Runner/ApiPolicy.cs +++ b/runtime/Sim.Runner/ApiPolicy.cs @@ -41,6 +41,13 @@ internal static class ApiPolicy ["System.Runtime.InteropServices.NativeMemory"] = "调用本机代码", }; + /// 不允许的类型里单独放行的成员:教材用 AppDomain.CurrentDomain.BaseDirectory 拼出保存照片的路径 + private static readonly HashSet AllowedMembers = new() + { + "System.AppDomain.CurrentDomain", + "System.AppDomain.BaseDirectory", + }; + public static IEnumerable Check(CSharpCompilation compilation) { foreach (var tree in compilation.SyntaxTrees) @@ -69,6 +76,7 @@ internal static class ApiPolicy if (symbol is IDynamicTypeSymbol) return "dynamic 动态调用"; // using 指令里的命名空间不算:VS 生成的 AssemblyInfo.cs 会 using System.Runtime.InteropServices if (symbol is INamespaceSymbol) return null; + if (symbol is IPropertySymbol && AllowedMembers.Contains($"{symbol.ContainingType.ToDisplayString()}.{symbol.Name}")) return null; var type = symbol as INamedTypeSymbol ?? symbol.ContainingType; // 方法的返回值、参数类型由调用处检查;这里看符号本身属于哪个类型 for (var t = type; t != null; t = t.ContainingType) diff --git a/runtime/Sim.Runner/StudentProgramHost.cs b/runtime/Sim.Runner/StudentProgramHost.cs index 708a969..4f79682 100644 --- a/runtime/Sim.Runner/StudentProgramHost.cs +++ b/runtime/Sim.Runner/StudentProgramHost.cs @@ -34,6 +34,8 @@ internal static class StudentProgramHost CultureInfo.CurrentCulture = zhCN; CultureInfo.CurrentUICulture = zhCN; + UseWritableBaseDirectory(dir); + var assembly = AssemblyLoadContext.Default.LoadFromStream( new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.AssemblyFile))), new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.PdbFile)))); @@ -81,6 +83,28 @@ internal static class StudentProgramHost return 0; } + /// + /// 学生程序的"程序所在目录":和在 VS 里按 F5 一样是工程下的 bin/Debug/,也是当前目录,可以写文件 + /// (教材把摄像头照片存到 AppDomain.CurrentDomain.BaseDirectory + "Camera/")。 + /// 沙箱里工程目录只读,改用沙箱自己的临时目录;两种情况下文件都在程序结束后清掉。 + /// + private static void UseWritableBaseDirectory(string projectDir) + { + string baseDir; + try + { + baseDir = Directory.CreateDirectory(Path.Combine(projectDir, "bin", "Debug")).FullName; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + baseDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "StudentApp", "bin", "Debug")).FullName; + } + baseDir = Path.TrimEndingDirectorySeparator(baseDir) + Path.DirectorySeparatorChar; + AppContext.SetData("APP_CONTEXT_BASE_DIRECTORY", baseDir); + Directory.SetCurrentDirectory(baseDir); + SimHost.AppBaseDirectory = baseDir; + } + /// 读取浏览器发来的操作,排队到界面线程执行 private static void ReadActions() { diff --git a/runtime/Sim.WinForms/Drawing/Image.cs b/runtime/Sim.WinForms/Drawing/Image.cs new file mode 100644 index 0000000..fa85d5f --- /dev/null +++ b/runtime/Sim.WinForms/Drawing/Image.cs @@ -0,0 +1,105 @@ +using System.ComponentModel; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using Sim.Host; + +namespace System.Drawing; + +/// +/// 图片。System.Drawing 在 Linux 上不受支持,这里只保存图片文件的原始数据(JPEG、PNG、GIF、BMP)和尺寸: +/// 能显示在 PictureBox 上、保存成文件、从文件或流读回,不能逐像素绘制。 +/// +public abstract class Image : IDisposable, ICloneable +{ + private string? _simKey; + + private protected Image(byte[] data) + { + Data = data; + (RawFormat, Width, Height) = ReadHeader(data) ?? throw new ArgumentException("参数无效:不是 JPEG、PNG、GIF 或 BMP 格式的图片。"); + } + + internal byte[] Data { get; } + + public int Width { get; } + public int Height { get; } + public Size Size => new(Width, Height); + public ImageFormat RawFormat { get; } + public object? Tag { get; set; } + + /// 平台内部使用:图片内容的哈希,浏览器按它缓存图片,同一张图只传一次 + [EditorBrowsable(EditorBrowsableState.Never)] + public string SimKey => _simKey ??= Convert.ToHexString(SHA1.HashData(Data)).ToLowerInvariant(); + + internal string MimeType => RawFormat.MimeType; + + public static Image FromFile(string filename) => new Bitmap(filename); + + public static Image FromStream(Stream stream) => new Bitmap(stream); + + public void Save(string filename) => Save(filename, RawFormat); + + /// 按原格式写出图片数据(不做格式转换)。目录不存在时和 GDI+ 一样报"一般性错误" + public void Save(string filename, ImageFormat format) + { + try + { + File.WriteAllBytes(filename, Data); + } + catch (DirectoryNotFoundException) + { + throw new ExternalException("GDI+ 中发生一般性错误。(保存图片的文件夹不存在,先用 Directory.CreateDirectory 创建)"); + } + SimHost.ImageSaved(Path.GetFullPath(filename), this); + } + + public void Save(Stream stream, ImageFormat format) => stream.Write(Data, 0, Data.Length); + + public object Clone() => new Bitmap(this); + + public void Dispose() => GC.SuppressFinalize(this); + + private static (ImageFormat, int, int)? ReadHeader(byte[] d) + { + if (d.Length >= 24 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G') + return (ImageFormat.Png, BigEndian(d, 16), BigEndian(d, 20)); + if (d.Length >= 10 && d[0] == 'G' && d[1] == 'I' && d[2] == 'F') + return (ImageFormat.Gif, d[6] | d[7] << 8, d[8] | d[9] << 8); + if (d.Length >= 26 && d[0] == 'B' && d[1] == 'M') + return (ImageFormat.Bmp, BitConverter.ToInt32(d, 18), Math.Abs(BitConverter.ToInt32(d, 22))); + if (d.Length >= 4 && d[0] == 0xFF && d[1] == 0xD8) + { + // 依次跳过各段,找到记录尺寸的 SOF 段 + var i = 2; + while (i + 9 < d.Length && d[i] == 0xFF) + { + var marker = d[i + 1]; + var length = d[i + 2] << 8 | d[i + 3]; + if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC) + return (ImageFormat.Jpeg, d[i + 7] << 8 | d[i + 8], d[i + 5] << 8 | d[i + 6]); + i += 2 + length; + } + } + return null; + } + + private static int BigEndian(byte[] d, int offset) => d[offset] << 24 | d[offset + 1] << 16 | d[offset + 2] << 8 | d[offset + 3]; +} + +public sealed class Bitmap : Image +{ + public Bitmap(string filename) : base(File.ReadAllBytes(filename)) { } + + public Bitmap(Stream stream) : base(ReadAll(stream)) { } + + public Bitmap(Image original) : base(original.Data) { } + + private static byte[] ReadAll(Stream stream) + { + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } +} diff --git a/runtime/Sim.WinForms/Drawing/ImageFormat.cs b/runtime/Sim.WinForms/Drawing/ImageFormat.cs new file mode 100644 index 0000000..ef8205c --- /dev/null +++ b/runtime/Sim.WinForms/Drawing/ImageFormat.cs @@ -0,0 +1,23 @@ +namespace System.Drawing.Imaging; + +/// 图片格式。Image.Save 时只用来对应文件类型,不做格式转换 +public sealed class ImageFormat +{ + private readonly string _name; + + private ImageFormat(string name, string mimeType) + { + _name = name; + MimeType = mimeType; + } + + internal string MimeType { get; } + + public static ImageFormat Jpeg { get; } = new("Jpeg", "image/jpeg"); + public static ImageFormat Png { get; } = new("Png", "image/png"); + public static ImageFormat Gif { get; } = new("Gif", "image/gif"); + public static ImageFormat Bmp { get; } = new("Bmp", "image/bmp"); + public static ImageFormat Icon { get; } = new("Icon", "image/x-icon"); + + public override string ToString() => _name; +} diff --git a/runtime/Sim.WinForms/Forms/PictureBox.cs b/runtime/Sim.WinForms/Forms/PictureBox.cs new file mode 100644 index 0000000..ad94ebe --- /dev/null +++ b/runtime/Sim.WinForms/Forms/PictureBox.cs @@ -0,0 +1,84 @@ +using System.ComponentModel; +using System.Drawing; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +public enum PictureBoxSizeMode { Normal, StretchImage, AutoSize, CenterImage, Zoom } + +/// 图片框(教材项目 4 显示摄像头画面) +public class PictureBox : Control, ISupportInitialize +{ + private Image? _image; + private PictureBoxSizeMode _sizeMode; + private BorderStyle _borderStyle; + + protected override Size DefaultSize => new(100, 50); + + /// + /// 要显示的图片。不检查调用线程:教材在摄像头回调(后台线程)里直接给 Image 赋值, + /// 真实 WinForms 的 PictureBox 也不会因此报错。 + /// + public Image? Image + { + get => _image; + set + { + LegacyThreads.CheckPoint(); + if (ReferenceEquals(_image, value)) return; + _image = value; + NotifyChanged(nameof(Image), value); + if (_sizeMode == PictureBoxSizeMode.AutoSize) FitToImage(); + } + } + + public PictureBoxSizeMode SizeMode + { + get => _sizeMode; + set + { + if (SetProp(ref _sizeMode, value, nameof(SizeMode)) && value == PictureBoxSizeMode.AutoSize) FitToImage(); + } + } + + public BorderStyle BorderStyle + { + get => _borderStyle; + set => SetProp(ref _borderStyle, value, nameof(BorderStyle)); + } + + public string? ImageLocation { get; set; } + public Image? ErrorImage { get; set; } + public Image? InitialImage { get; set; } + public bool WaitOnLoad { get; set; } + + /// 从文件加载图片(只支持本地文件) + public void Load(string url) + { + ImageLocation = url; + Image = Image.FromFile(url); + } + + public void Load() + { + if (!string.IsNullOrEmpty(ImageLocation)) Load(ImageLocation); + } + + // Designer.cs 里 VS 生成 ((ISupportInitialize)pictureBox1).BeginInit() / EndInit() + public void BeginInit() { } + public void EndInit() { } + + private void FitToImage() + { + if (_image != null) Size = _image.Size; + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["Image"] = PropValue.From(_image); + props["SizeMode"] = _sizeMode.ToString(); + props["BorderStyle"] = _borderStyle.ToString(); + } +} diff --git a/runtime/Sim.WinForms/Host/PropValue.cs b/runtime/Sim.WinForms/Host/PropValue.cs index 517cc0c..d1a05ae 100644 --- a/runtime/Sim.WinForms/Host/PropValue.cs +++ b/runtime/Sim.WinForms/Host/PropValue.cs @@ -31,6 +31,7 @@ public static class PropValue ["underline"] = f.Underline, ["strikeout"] = f.Strikeout, }, + Image image => new JsonObject { ["key"] = SimHost.ImageKey(image), ["width"] = image.Width, ["height"] = image.Height }, Padding p => new JsonObject { ["left"] = p.Left, ["top"] = p.Top, ["right"] = p.Right, ["bottom"] = p.Bottom }, IEnumerable list => ListOf(list), _ => JsonValueOf(value.ToString() ?? ""), diff --git a/runtime/Sim.WinForms/Host/SimHost.cs b/runtime/Sim.WinForms/Host/SimHost.cs index 1a184bc..9b7a1bf 100644 --- a/runtime/Sim.WinForms/Host/SimHost.cs +++ b/runtime/Sim.WinForms/Host/SimHost.cs @@ -75,6 +75,42 @@ public static class SimHost _output.WriteLine(new JsonObject { ["type"] = "patch", ["changes"] = changes }.ToJsonString(JsonOptions)); } + // ---------- 图片 ---------- + + private static readonly HashSet SentImages = new(); + + /// + /// 学生程序的"程序所在目录"(AppDomain.CurrentDomain.BaseDirectory),可写,程序结束后清掉。 + /// 运行器启动时设定;保存的图片按相对这里的路径显示在网页的照片列表里。 + /// + public static string AppBaseDirectory { get; set; } = AppContext.BaseDirectory; + + /// 图片在浏览器端的编号;第一次用到时先把图片数据发过去,浏览器缓存后同一张图不再重发 + public static string ImageKey(System.Drawing.Image image) + { + var key = image.SimKey; + bool isNew; + lock (SentImages) isNew = SentImages.Add(key); + if (isNew) + { + Send(new JsonObject + { + ["type"] = "image", + ["key"] = key, + ["mime"] = image.MimeType, + ["data"] = Convert.ToBase64String(image.Data), + }); + } + return key; + } + + /// 学生程序用 Image.Save 保存了图片:网页的照片列表里显示出来 + public static void ImageSaved(string fullPath, System.Drawing.Image image) + { + var path = fullPath.StartsWith(AppBaseDirectory, StringComparison.Ordinal) ? fullPath[AppBaseDirectory.Length..] : fullPath; + Send(new JsonObject { ["type"] = "imageSaved", ["path"] = path, ["image"] = ImageKey(image) }); + } + public static void SendConsole(string text) => Send(new JsonObject { ["type"] = "console", ["text"] = text }); public static void SendDevice(string device, string action, object? data) diff --git a/samples/p4-t2-网络摄像机/Form1.Designer.cs b/samples/p4-t2-网络摄像机/Form1.Designer.cs new file mode 100644 index 0000000..e97436e --- /dev/null +++ b/samples/p4-t2-网络摄像机/Form1.Designer.cs @@ -0,0 +1,166 @@ +namespace 网络摄像机 +{ + partial class Form1 + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.pictureBox1 = new System.Windows.Forms.PictureBox(); + this.btn_up = new System.Windows.Forms.Button(); + this.btn_left = new System.Windows.Forms.Button(); + this.btn_right = new System.Windows.Forms.Button(); + this.btn_down = new System.Windows.Forms.Button(); + this.btn_camera = new System.Windows.Forms.Button(); + this.btn_open = new System.Windows.Forms.Button(); + this.label1 = new System.Windows.Forms.Label(); + this.txt_ip = new System.Windows.Forms.TextBox(); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).BeginInit(); + this.SuspendLayout(); + // + // pictureBox1 + // + this.pictureBox1.BackColor = System.Drawing.Color.Black; + this.pictureBox1.Location = new System.Drawing.Point(25, 41); + this.pictureBox1.Name = "pictureBox1"; + this.pictureBox1.Size = new System.Drawing.Size(640, 480); + this.pictureBox1.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage; + this.pictureBox1.TabIndex = 0; + this.pictureBox1.TabStop = false; + // + // btn_up + // + this.btn_up.Location = new System.Drawing.Point(763, 166); + this.btn_up.Name = "btn_up"; + this.btn_up.Size = new System.Drawing.Size(33, 33); + this.btn_up.TabIndex = 1; + this.btn_up.Text = "↑"; + this.btn_up.UseVisualStyleBackColor = true; + this.btn_up.Click += new System.EventHandler(this.btn_up_Click); + // + // btn_left + // + this.btn_left.Location = new System.Drawing.Point(707, 219); + this.btn_left.Name = "btn_left"; + this.btn_left.Size = new System.Drawing.Size(33, 33); + this.btn_left.TabIndex = 2; + this.btn_left.Text = "←"; + this.btn_left.UseVisualStyleBackColor = true; + this.btn_left.Click += new System.EventHandler(this.btn_left_Click); + // + // btn_right + // + this.btn_right.Location = new System.Drawing.Point(820, 219); + this.btn_right.Name = "btn_right"; + this.btn_right.Size = new System.Drawing.Size(33, 33); + this.btn_right.TabIndex = 3; + this.btn_right.Text = "→"; + this.btn_right.UseVisualStyleBackColor = true; + this.btn_right.Click += new System.EventHandler(this.btn_right_Click); + // + // btn_down + // + this.btn_down.Location = new System.Drawing.Point(763, 268); + this.btn_down.Name = "btn_down"; + this.btn_down.Size = new System.Drawing.Size(33, 33); + this.btn_down.TabIndex = 4; + this.btn_down.Text = "↓"; + this.btn_down.UseVisualStyleBackColor = true; + this.btn_down.Click += new System.EventHandler(this.btn_down_Click); + // + // btn_camera + // + this.btn_camera.Location = new System.Drawing.Point(743, 347); + this.btn_camera.Name = "btn_camera"; + this.btn_camera.Size = new System.Drawing.Size(75, 33); + this.btn_camera.TabIndex = 7; + this.btn_camera.Text = "拍照"; + this.btn_camera.UseVisualStyleBackColor = true; + this.btn_camera.Click += new System.EventHandler(this.btn_camera_Click); + // + // btn_open + // + this.btn_open.Location = new System.Drawing.Point(763, 219); + this.btn_open.Name = "btn_open"; + this.btn_open.Size = new System.Drawing.Size(33, 33); + this.btn_open.TabIndex = 9; + this.btn_open.Text = "开"; + this.btn_open.UseVisualStyleBackColor = true; + this.btn_open.Click += new System.EventHandler(this.btn_open_Click); + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(685, 69); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(53, 12); + this.label1.TabIndex = 10; + this.label1.Text = "摄像机IP"; + // + // txt_ip + // + this.txt_ip.Location = new System.Drawing.Point(744, 66); + this.txt_ip.Name = "txt_ip"; + this.txt_ip.Size = new System.Drawing.Size(111, 21); + this.txt_ip.TabIndex = 11; + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(884, 562); + this.Controls.Add(this.txt_ip); + this.Controls.Add(this.label1); + this.Controls.Add(this.btn_open); + this.Controls.Add(this.btn_camera); + this.Controls.Add(this.btn_down); + this.Controls.Add(this.btn_right); + this.Controls.Add(this.btn_left); + this.Controls.Add(this.btn_up); + this.Controls.Add(this.pictureBox1); + this.Name = "Form1"; + this.Text = "网络摄像机"; + this.Load += new System.EventHandler(this.Form1_Load); + ((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).EndInit(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.PictureBox pictureBox1; + private System.Windows.Forms.Button btn_up; + private System.Windows.Forms.Button btn_left; + private System.Windows.Forms.Button btn_right; + private System.Windows.Forms.Button btn_down; + private System.Windows.Forms.Button btn_camera; + private System.Windows.Forms.Button btn_open; + private System.Windows.Forms.Label label1; + private System.Windows.Forms.TextBox txt_ip; + + } +} + diff --git a/samples/p4-t2-网络摄像机/Form1.cs b/samples/p4-t2-网络摄像机/Form1.cs new file mode 100644 index 0000000..665696c --- /dev/null +++ b/samples/p4-t2-网络摄像机/Form1.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.IO; +using DeviceCtrlLibrary; + +namespace 网络摄像机 +{ + public partial class Form1 : Form + { + public Form1() + { + InitializeComponent(); + } + + static string CameraPath = AppDomain.CurrentDomain.BaseDirectory + "Camera/";//拍照保存路径 + static string IpCameraIPAddress = "192.168.14.131";//网络摄像机IP + bool IsOpen = false; + Image ReadImage = null;//存储摄像机图片 + + private void Form1_Load(object sender, EventArgs e) + { + //判断拍照保存文件夹是否存在,若不存在则先创建 + if (!Directory.Exists(CameraPath)) + Directory.CreateDirectory(CameraPath); + DeviceCtrlHelper.ipCameraHelper.ImageRead += ipCameraHelper_ImageRead;//绑定图片显示事件 + } + + void ipCameraHelper_ImageRead(ImageReadEvenArgs e) + { + ReadImage = e.img;//保存摄像机拍到的图片 + pictureBox1.Image = ReadImage;//显示在图片框上 + } + + private void btn_open_Click(object sender, EventArgs e) + { + IsOpen = !IsOpen; + if (IsOpen) + { + IpCameraIPAddress = txt_ip.Text; + DeviceCtrlHelper.ipCameraHelper.Open(IpCameraIPAddress, "admin", "");//用户名admin,密码为空 + DeviceCtrlHelper.ipCameraHelper.Start();//开始传送图像 + btn_open.Text = "关"; + } + else + { + DeviceCtrlHelper.ipCameraHelper.Stop();//停止传送图像 + btn_open.Text = "开"; + } + } + + private void btn_up_Click(object sender, EventArgs e) + { + DeviceCtrlHelper.ipCameraHelper.PanUp();//向上转 + } + + private void btn_down_Click(object sender, EventArgs e) + { + DeviceCtrlHelper.ipCameraHelper.PanDown();//向下转 + } + + private void btn_left_Click(object sender, EventArgs e) + { + DeviceCtrlHelper.ipCameraHelper.PanLeft();//向左转 + } + + private void btn_right_Click(object sender, EventArgs e) + { + DeviceCtrlHelper.ipCameraHelper.PanRight();//向右转 + } + + private void btn_camera_Click(object sender, EventArgs e) + { + //拍照:把当前图像以时间命名保存到Camera文件夹 + if (ReadImage != null) + ReadImage.Save(CameraPath + DateTime.Now.ToString("yyyyMMdd_HH mm ss") + ".jpg"); + } + } +} diff --git a/samples/p4-t2-网络摄像机/Program.cs b/samples/p4-t2-网络摄像机/Program.cs new file mode 100644 index 0000000..b07df3b --- /dev/null +++ b/samples/p4-t2-网络摄像机/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace 网络摄像机 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/samples/p4-t2-网络摄像机/checks.yaml b/samples/p4-t2-网络摄像机/checks.yaml new file mode 100644 index 0000000..5ad6971 --- /dev/null +++ b/samples/p4-t2-网络摄像机/checks.yaml @@ -0,0 +1,40 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 能正确打开、关闭摄像机 + steps: + - text: { control: txt_ip, value: 192.168.2.50 } + - click: btn_open + - expect: { control: btn_open, text: 关, desc: 按钮文字变成"关" } + - expect: { camera: on } + - click: btn_open + - expect: { control: btn_open, text: 开, desc: 按钮文字变回"开" } + - expect: { camera: off } + +- title: 能正确显示图像 + steps: + - text: { control: txt_ip, value: 192.168.2.50 } + - click: btn_open + - expect: { control: pictureBox1, image: true, within: 3s, desc: 显示摄像机拍到的图像 } + +- title: 能控制摄像机上下左右旋转 + steps: + - text: { control: txt_ip, value: 192.168.2.50 } + - click: btn_open + - click: btn_up + - expect: { camera: up, desc: 点"↑"后向上转 } + - click: btn_left + - expect: { camera: left, desc: 点"←"后向左转 } + - click: btn_down + - click: btn_down + - expect: { camera: down, desc: 点"↓"后向下转 } + - click: btn_right + - click: btn_right + - expect: { camera: right, desc: 点"→"后向右转 } + +- title: 能抓拍摄像机当前图像 + steps: + - text: { control: txt_ip, value: 192.168.2.50 } + - click: btn_open + - expect: { control: pictureBox1, image: true, within: 3s, desc: 显示摄像机拍到的图像 } + - click: btn_camera + - expect: { savedImage: Camera/, desc: 把照片保存到 Camera 文件夹 } diff --git a/samples/p4-t2-网络摄像机/meta.json b/samples/p4-t2-网络摄像机/meta.json new file mode 100644 index 0000000..e4168c4 --- /dev/null +++ b/samples/p4-t2-网络摄像机/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目4 任务2 控制网络摄像机", + "source": "教材书 p78–81,界面取自配套参考工程,标签文字按书上表格改为“摄像机IP”;补上书上漏写的 using System.IO;" +} diff --git a/server/src/board.ts b/server/src/board.ts index 738f024..19f64dc 100644 --- a/server/src/board.ts +++ b/server/src/board.ts @@ -15,6 +15,7 @@ export function defaultBoardState(): BoardState { relays: [false, false, false, false, false], zigbee: [false, false], ledText: '', + camera: { on: false, x: 3, y: 1 }, } } @@ -69,11 +70,25 @@ export class Board { this.state.zigbee[relay] = on === 1 return true } + case 'camera/on': + this.state.camera.on = message.data === true + return true + case 'camera/pan': { + const [x, y] = message.data as [number, number] + this.state.camera.x = x + this.state.camera.y = y + return true + } default: return false } } + /** 学生程序结束:摄像头不再传画面,云台回到正中(下次运行的程序从正中开始) */ + programExited() { + this.state.camera = { on: false, x: 3, y: 1 } + } + reset() { this.state = defaultBoardState() } diff --git a/server/src/grading/checks.ts b/server/src/grading/checks.ts index 4f57c6a..deb1a6b 100644 --- a/server/src/grading/checks.ts +++ b/server/src/grading/checks.ts @@ -28,6 +28,12 @@ export type Expectation = | { kind: 'relay'; index: number; on: boolean } | { kind: 'zigbee'; index: number; on: boolean } | { kind: 'messageBox'; text: string } + /** 图片框里显示了图片 */ + | { kind: 'image'; control: string } + /** 摄像头在传画面(on)/ 已关闭(off),或云台比开始时朝某个方向转过 */ + | { kind: 'camera'; state: 'on' | 'off' | 'up' | 'down' | 'left' | 'right' } + /** 程序用 Image.Save 保存了图片,路径包含这段文字(如 Camera/) */ + | { kind: 'savedImage'; path: string } | { kind: 'console'; text: string } /** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */ @@ -59,6 +65,12 @@ function parseExpectation(raw: Record, where: string): Expectat if ('led' in raw) return { kind: 'led', text: str(raw.led, where, ' LED 屏应显示的文字') } if ('messageBox' in raw) return { kind: 'messageBox', text: str(raw.messageBox, where, '提示框应包含的文字') } if ('console' in raw) return { kind: 'console', text: str(raw.console, where, '控制台应输出的文字') } + if ('camera' in raw) { + const state = String(raw.camera) + if (!['on', 'off', 'up', 'down', 'left', 'right'].includes(state)) throw new Error(`${where}:camera 要写 on、off、up、down、left 或 right`) + return { kind: 'camera', state: state as Extract['state'] } + } + if ('savedImage' in raw) return { kind: 'savedImage', path: str(raw.savedImage, where, '保存的图片路径应包含的文字') } if ('relay' in raw) return { kind: 'relay', index: Number(raw.relay), on: bool(raw.on, where, 'on') } if ('zigbee' in raw) return { kind: 'zigbee', index: Number(raw.zigbee), on: bool(raw.on, where, 'on') } @@ -69,6 +81,7 @@ function parseExpectation(raw: Record, where: string): Expectat return { kind: 'matches', control, pattern: new RegExp(source), source } } if ('unchanged' in raw) return { kind: 'unchanged', control, ms: duration(raw.unchanged, where) } + if ('image' in raw) return { kind: 'image', control } for (const [key, prop] of Object.entries(PROP_KEYS)) { if (key in raw) return { kind: 'prop', control, prop, value: STRING_PROPS.has(prop) ? String(raw[key]) : bool(raw[key], where, key) } } diff --git a/server/src/grading/driver.ts b/server/src/grading/driver.ts index b54ab77..a0c5c49 100644 --- a/server/src/grading/driver.ts +++ b/server/src/grading/driver.ts @@ -16,6 +16,8 @@ export class ProgramDriver { private readonly nodes = new Map() private readonly parents = new Map() readonly messageBoxes: { text: string; caption: string }[] = [] + /** 程序用 Image.Save 保存的图片路径 */ + readonly savedImages: string[] = [] readonly exceptions: string[] = [] console = '' exited = false @@ -106,6 +108,9 @@ export class ProgramDriver { case 'device': this.board.applyDevice(message) break + case 'imageSaved': + this.savedImages.push(message.path) + break case 'console': this.console += message.text break diff --git a/server/src/grading/grader.ts b/server/src/grading/grader.ts index 0e43774..d3eb24a 100644 --- a/server/src/grading/grader.ts +++ b/server/src/grading/grader.ts @@ -11,6 +11,9 @@ const START_TIMEOUT = 10_000 const RELAY_NAMES = ['报警灯', '照明灯', '路灯', '插座 1', '插座 2'] const ZIGBEE_NAMES = ['ZigBee 节点 0', '照明灯 1'] +/** 摄像头云台开始时的位置(见 IPCameraHelper.cs) */ +const CAMERA_START = { x: 3, y: 1 } +const CAMERA_WORDS = { on: '开启(传送画面)', off: '关闭', up: '向上转', down: '向下转', left: '向左转', right: '向右转' } // ---------- 并发限制 ---------- let running = 0 @@ -160,6 +163,14 @@ function satisfied(driver: ProgramDriver, e: Exclude box.text.includes(e.text)) case 'console': return driver.console.includes(e.text) + case 'image': + return Boolean(props(e.control)?.Image) + case 'camera': { + const { on, x, y } = driver.board.state.camera + return { on, off: !on, up: y < CAMERA_START.y, down: y > CAMERA_START.y, left: x < CAMERA_START.x, right: x > CAMERA_START.x }[e.state] + } + case 'savedImage': + return driver.savedImages.some((path) => path.includes(e.path)) } } @@ -195,5 +206,13 @@ function describeFailure(driver: ProgramDriver, e: Exclude `"${p}"`).join('、') + return `期望${prefix}${desc ?? `保存图片到"${e.path}"`},${saved ? `实际保存的是 ${saved}` : '实际没有保存图片'}` + } } } diff --git a/server/src/run-session.ts b/server/src/run-session.ts index 30658cb..9eba59a 100644 --- a/server/src/run-session.ts +++ b/server/src/run-session.ts @@ -108,6 +108,8 @@ export class RunSession { clearTimeout(timeout) if (this.proc !== proc) return this.proc = undefined + this.board.programExited() + this.sendBoard() const cpuMessage = cpuLimitMessage(code, proc.signalCode) if (cpuMessage) this.send({ type: 'console', text: cpuMessage }) this.send({ type: 'exited', code }) @@ -159,6 +161,8 @@ export class RunSession { if (!proc) return proc.kill() await proc.exited + this.board.programExited() + this.sendBoard() } private async cleanup() { diff --git a/server/test/grading.test.ts b/server/test/grading.test.ts index 5d4c92d..e34c527 100644 --- a/server/test/grading.test.ts +++ b/server/test/grading.test.ts @@ -31,7 +31,7 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st describe('参考答案全部通过', async () => { const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort() - test('每个示例都有评分规则', () => expect(ids.length).toBe(12)) + test('每个示例都有评分规则', () => expect(ids.length).toBe(13)) for (const id of ids) { test(id, async () => { const result = await grade(await prepare(id, id), await checksOf(id)) @@ -80,6 +80,14 @@ describe('改坏的代码', () => { expect(stop.message).toStartWith('期望 txt_time 不再更新时间(定时器应已停止),实际从') }, 60000) + test('摄像机:忘了绑定画面事件', async () => { + const id = 'p4-t2-网络摄像机' + const dir = await prepare(id, 'camera', { file: 'Form1.cs', from: 'DeviceCtrlHelper.ipCameraHelper.ImageRead += ipCameraHelper_ImageRead;', to: '' }) + const result = await grade(dir, await checksOf(id)) + expect(result.results.map((r) => r.passed)).toEqual([true, false, true, false]) + expect(result.results[1].message).toBe('期望3 秒内 pictureBox1 显示摄像机拍到的图像,实际是空的') + }, 60000) + test('关闭时忘了挂起线程', async () => { const id = 'p5-t2-烟雾' const dir = await prepare(id, 'thread', { file: 'Form1.cs', from: 'thd.Suspend();//挂起线程', to: '' }) diff --git a/server/test/runner.test.ts b/server/test/runner.test.ts index a1ee074..62089e4 100644 --- a/server/test/runner.test.ts +++ b/server/test/runner.test.ts @@ -318,6 +318,41 @@ test('项目4 任务1:红外对射被遮挡时显示"发现入侵"', async () await even('发现入侵') }, 30000) +test('项目4 任务2:打开摄像机显示画面,转动后画面变化,有人翻墙时画面变化,拍照保存到 Camera 文件夹', async () => { + const { runner, id } = await startSample('p4-t2-网络摄像机') + const pictures = () => runner.next('patch', (m) => m.changes.some((c) => c.id === id('pictureBox1') && c.prop === 'Image'), 5000) + const imageOf = (patch: Extract) => + patch.changes.find((c) => c.id === id('pictureBox1') && c.prop === 'Image')!.value as { key: string; width: number; height: number } + + runner.send({ type: 'action', id: id('txt_ip'), action: 'text', value: '192.168.2.50' }) + const first = pictures() + const cameraOn = runner.next('device', (m) => m.device === 'camera' && m.action === 'on' && m.data === true) + runner.send({ type: 'action', id: id('btn_open'), action: 'click' }) + await cameraOn + const center = imageOf(await first) + expect(center).toMatchObject({ width: 640, height: 480 }) + // 图片数据先于(或随)属性变化发给浏览器,同一张图只发一次 + const sent = runner.messages.filter((m) => m.type === 'image' && m.key === center.key) + expect(sent.length).toBe(1) + expect(sent[0]).toMatchObject({ mime: 'image/jpeg' }) + + const up = pictures() + const pan = runner.next('device', (m) => m.device === 'camera' && m.action === 'pan') + runner.send({ type: 'action', id: id('btn_up'), action: 'click' }) + expect((await pan).data).toEqual([3, 0]) + expect(imageOf(await up).key).not.toBe(center.key) + + const intruder = pictures() + runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, false, true] }) + const withIntruder = imageOf(await intruder) + + const saved = runner.next('imageSaved') + runner.send({ type: 'action', id: id('btn_camera'), action: 'click' }) + const photo = await saved + expect(photo.path).toMatch(/^Camera\/\d{8}_\d{2} \d{2} \d{2}\.jpg$/) + expect(photo.image).toBe(withIntruder.key) +}, 30000) + test('项目4 任务3:开报警灯——实训台 DO0 动作,窗体上的报警灯亮', async () => { const { runner, id } = await startSample('p4-t3-报警灯') await openAdam4150(runner, id) @@ -483,6 +518,32 @@ test('ListBox:点某一项先选中(SelectedIndexChanged)再触发 Click expect(output).toBe('changed=1,乙\nclick\ndouble=乙\n') }, 30000) +test('图片:程序所在目录可写,Image.Save / FromFile 往返,文件夹不存在时和 GDI+ 一样报一般性错误', async () => { + const { runner } = await runMain( + 'image', + ` + string dir = AppDomain.CurrentDomain.BaseDirectory; + Console.WriteLine("base=" + dir.EndsWith("/bin/Debug/") + "," + (Directory.GetCurrentDirectory() + "/" == dir)); + // 1×1 的 PNG + byte[] png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="); + Image image = Image.FromStream(new MemoryStream(png)); + Directory.CreateDirectory(dir + "Pics"); + image.Save(dir + "Pics/a.png", ImageFormat.Png); + Image back = Image.FromFile("Pics/a.png"); + Console.WriteLine("size=" + back.Width + "x" + back.Height + "," + back.RawFormat); + try { image.Save(dir + "NoSuchDir/b.png"); } + catch (System.Runtime.InteropServices.ExternalException ex) { Console.WriteLine("error=" + ex.Message); }`, + 'using System.Drawing;\nusing System.Drawing.Imaging;', + ) + expect((await runner.waitFor('compiled')).diagnostics.filter((d) => d.severity === 'error')).toEqual([]) + await runner.finished() + const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('') + expect(output).toContain('base=True,True') + expect(output).toContain('size=1x1,Png') + expect(output).toContain('error=GDI+ 中发生一般性错误。') + expect(runner.messages.filter((m) => m.type === 'imageSaved').map((m) => (m as { path: string }).path)).toEqual(['Pics/a.png']) +}, 30000) + test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => { process.env.SIM_TEST_SECRET = '不该被看到' const { runner } = await runMain( @@ -493,7 +554,8 @@ test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别 Console.WriteLine("home=" + Directory.Exists("/home")); Console.WriteLine("secret=" + (Environment.GetEnvironmentVariable("SIM_TEST_SECRET") ?? "null")); Console.WriteLine("procs=" + Directory.GetDirectories("/proc").Count(d => char.IsDigit(Path.GetFileName(d)[0]))); - try { File.WriteAllText("hack.txt", "x"); Console.WriteLine("write=ok"); } catch (Exception) { Console.WriteLine("write=denied"); } + // 工程目录在沙箱里挂在 /sim/app(当前目录是可写的程序目录 bin/Debug,不是工程目录) + try { File.WriteAllText("/sim/app/hack.txt", "x"); Console.WriteLine("write=ok"); } catch (Exception) { Console.WriteLine("write=denied"); } File.WriteAllText("/tmp/ok.txt", "x"); Console.WriteLine("tmp=" + File.Exists("/tmp/ok.txt"));`, ) @@ -525,7 +587,7 @@ test('内存上限:不停分配内存的程序得到 OutOfMemoryException, expect(megabytes).toBeLessThanOrEqual(config.heapLimitMb) }, 30000) -test('编译检查:网络、启动程序、反射、dynamic 给出中文报错;AssemblyInfo.cs 里的特性不受影响', async () => { +test('编译检查:网络、启动程序、反射、dynamic、AppDomain 给出中文报错;AssemblyInfo.cs 里的特性、BaseDirectory 不受影响', async () => { const dir = resolve(ROOT, '.runs/test-policy') await rm(dir, { recursive: true, force: true }) await Bun.write( @@ -541,6 +603,8 @@ static class Program var client = new HttpClient(); typeof(string).GetMethod("Trim", Type.EmptyTypes).Invoke("x", null); dynamic d = 1; + string path = AppDomain.CurrentDomain.BaseDirectory + "Camera/"; + AppDomain.CurrentDomain.GetAssemblies(); } } `, @@ -562,6 +626,7 @@ using System.Runtime.InteropServices; [9, '平台不允许使用 System.Net.Http.HttpClient(网络访问)'], [10, '平台不允许使用 System.Reflection.MethodBase.Invoke(反射)'], [11, '平台不允许使用 dynamic(dynamic 动态调用)'], + [13, '平台不允许使用 System.AppDomain.GetAssemblies(加载程序集)'], ]) expect(result.diagnostics.filter((d) => d.file.includes('AssemblyInfo'))).toEqual([]) }, 30000) diff --git a/shared/protocol.ts b/shared/protocol.ts index 36c5775..86a8906 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -62,6 +62,8 @@ export interface BoardState { /** ZigBee 继电器节点 0、1(教材用节点 1 控制路灯) */ zigbee: boolean[] ledText: string + /** 网络摄像机:是否在传画面,云台位置(左右 0–6、上下 0–2,开始时在正中) */ + camera: { on: boolean; x: number; y: number } } export type AnalogKey = keyof BoardState['analog'] @@ -92,7 +94,11 @@ export type RunnerMessage = | { type: 'controlAdded'; parentId: string; index: number; node: ControlNode } | { type: 'controlRemoved'; id: string } | { type: 'messageBox'; requestId: number; text: string; caption: string; buttons: string; icon: string } - /** 学生程序操作了实训台输出:led/text、adam4150/do、zigbee/relay,data 见 DeviceCtrlHelper.cs */ + /** 图片数据(base64),同一张图只发一次;控件属性和 imageSaved 里用 key 引用 */ + | { type: 'image'; key: string; mime: string; data: string } + /** 学生程序用 Image.Save 保存了图片;path 相对程序所在目录,如 Camera/20241022_10 30 00.jpg */ + | { type: 'imageSaved'; path: string; image: string } + /** 学生程序操作了实训台输出:led/text、adam4150/do、zigbee/relay、camera/on、camera/pan,data 见 DeviceCtrlHelper.cs、IPCameraHelper.cs */ | { type: 'device'; device: string; action: string; data: unknown } | { type: 'console'; text: string } | { type: 'exception'; message: string; detail: string } diff --git a/web/src/designer/catalog.ts b/web/src/designer/catalog.ts index 51084bf..120283d 100644 --- a/web/src/designer/catalog.ts +++ b/web/src/designer/catalog.ts @@ -179,6 +179,28 @@ export const CONTROLS: ControlDef[] = [ ], initial: () => [['FormattingEnabled', bool(true)], ['ItemHeight', int(12)], ['Size', size(120, 88)]], }, + { + type: `${FORMS}.PictureBox`, + label: '图片框', + group: '常用控件', + namePrefix: 'pictureBox', + defaultEvent: 'Click', + props: [ + ...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor' && p.name !== 'Font'), + { + name: 'SizeMode', label: '显示方式', description: '图片在图片框里怎样显示', type: 'enum', enumType: `${FORMS}.PictureBoxSizeMode`, + options: ['Normal', 'StretchImage', 'AutoSize', 'CenterImage', 'Zoom'], + optionLabels: { Normal: '原样放在左上角', StretchImage: '拉伸填满', AutoSize: '图片框随图片大小', CenterImage: '居中', Zoom: '按比例缩放' }, + default: enumValue(`${FORMS}.PictureBoxSizeMode`, 'Normal'), category: '外观', + }, + { + name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`, + options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观', + }, + ], + events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), + initial: () => [['Size', size(100, 50)], ['TabStop', bool(false)]], + }, { type: `${FORMS}.CheckBox`, label: '复选框', diff --git a/web/src/run/RunArea.vue b/web/src/run/RunArea.vue index 6a2d6da..11a239a 100644 --- a/web/src/run/RunArea.vue +++ b/web/src/run/RunArea.vue @@ -1,8 +1,10 @@