commit 98be8331bc26bd49cd920af9210ddd74036125c8 Author: yuetsh <517252939@qq.com> Date: Thu Oct 22 10:15:14 2026 +0800 第 0 阶段:教材项目 2 任务 1 在网页中原样运行 - runtime:仿真 WinForms 子集(Form、Button、Label、TextBox、ComboBox、 GroupBox、Timer、MessageBox、界面线程消息循环、跨线程检查)、 教材设备类库(DeviceCtrlHelper、LED 控件)、运行器(常驻编译服务 + 运行模式) - server:Bun + Hono,WebSocket 会话,每次运行一个进程,消息转发 - web:Vue + Naive UI,WinForms 风格窗体渲染、MessageBox、实训台 LED 屏 - 端到端测试与 100 个程序并发的压力测试 - 教材逐任务摘要(docs/教材摘要) Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..714ac04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# 依赖与构建产物 +node_modules/ +dist/ +bin/ +obj/ +*.tsbuildinfo + +# 运行时临时目录 +.runs/ + +# 教材资料(体积大、有版权,不进仓库) +物联网编程与应用配套/ +*.pdf + +# 编辑器 +.vscode/ +.idea/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..2073756 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# 物联网编程仿真平台 + +面向中职《物联网编程与应用(第 2 版)》的在线仿真平台:学生在浏览器里写 C# WinForms 程序, +程序在服务器上运行,界面和虚拟实训台的效果在网页上实时显示。设计方案见 Claude Docs 中的《物联网 C# 虚拟仿真教学平台设计方案》。 + +## 目录 + +| 目录 | 内容 | +|---|---| +| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 | +| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 | +| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) | +| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、运行器(`Sim.Runner`) | +| `samples/` | 示例工程(按教材任务整理) | +| `docs/教材摘要/` | 教材逐任务摘要:控件属性表、扩展题原文、评价表 | + +## 本地开发 + +需要 Bun 和 .NET 10 SDK。 + +```bash +bun install # 安装前端和后端依赖 +bun run build:runtime # 编译 C# 运行时 +bun run dev:server # 启动后端(端口 3000) +bun run dev:web # 启动前端(端口 5173),浏览器打开 http://localhost:5173 +``` + +测试: + +```bash +cd server +bun test # 端到端测试:编译并运行示例,模拟浏览器操作 +bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序 +``` + +## 运行器的两种模式 + +`Sim.Runner` 通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条): + +- `compile-server`:常驻编译服务,全服务器共用一个。Roslyn 只加载一次,编译结果写回工程目录。 +- `run <工程目录>`:运行已编译的学生程序,每次运行一个进程,不加载编译器。 + +## 当前进度:第 0 阶段(技术验证)已跑通 + +教材项目 2 任务 1(LED 显示屏)的书上代码原样运行:选串口、打开、推送文字,窗体上的 LED 控件和实训台 LED 屏都会滚动显示;不打开串口直接推送会弹出 "Error:发送失败"。 + +本机实测(16 核、28 GB): + +| 指标 | 结果 | +|---|---| +| 编译(编译服务预热后) | 约 10–300 ms | +| 启动到窗体显示 | 约 100 ms | +| 单次操作往返 | 约 15 ms | +| 100 个程序同时运行:内存 | 合计约 1.1 GB(PSS,平均每个 11 MB);另加编译服务约 150 MB | +| 100 个程序同时点击:往返 | 中位数约 60 ms,P95 约 75 ms | + +尚未完成: + +- 学生代码目前直接在本机进程里运行,**还没有沙箱**,只能在本机开发时使用;部署前要改为在 Docker 容器中运行。 +- 设备状态暂时放在运行器进程里,用的是固定默认值;第 1 阶段改为由 Bun 服务器上的虚拟实训台统一管理。 diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..bcbf8be --- /dev/null +++ b/bun.lock @@ -0,0 +1,289 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "iot-sim", + }, + "server": { + "name": "@iot/server", + "dependencies": { + "@iot/shared": "workspace:*", + "hono": "^4.7.0", + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^5.8.0", + }, + }, + "shared": { + "name": "@iot/shared", + }, + "web": { + "name": "@iot/web", + "dependencies": { + "@iot/shared": "workspace:*", + "naive-ui": "^2.41.0", + "vue": "^3.5.0", + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "@vitejs/plugin-vue": "^6.0.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vue-tsc": "^3.0.0", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="], + + "@css-render/vue3-ssr": ["@css-render/vue3-ssr@0.15.14", "", { "peerDependencies": { "vue": "^3.0.11" } }, "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g=="], + + "@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + + "@iot/server": ["@iot/server@workspace:server"], + + "@iot/shared": ["@iot/shared@workspace:shared"], + + "@iot/web": ["@iot/web@workspace:web"], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.12", "", { "os": "android", "cpu": "arm" }, "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.12", "", { "os": "android", "cpu": "arm64" }, "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.12", "", { "os": "linux", "cpu": "arm" }, "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.12", "", { "os": "linux", "cpu": "x64" }, "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.12", "", { "os": "linux", "cpu": "x64" }, "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.12", "", { "os": "none", "cpu": "arm64" }, "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.12", "", { "os": "win32", "cpu": "x64" }, "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.12", "", { "os": "win32", "cpu": "x64" }, "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw=="], + + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/lodash": ["@types/lodash@4.17.26", "", {}, "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA=="], + + "@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="], + + "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="], + + "@vue/language-core": ["@vue/language-core@3.3.13", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.44", "", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.44", "", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="], + + "@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="], + + "alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="], + + "async-validator": ["async-validator@4.2.5", "", {}, "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="], + + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], + + "css-render": ["css-render@0.15.14", "", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + + "date-fns-tz": ["date-fns-tz@3.2.0", "", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "evtd": ["evtd@0.2.4", "", {}, "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="], + + "hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "naive-ui": ["naive-ui@2.45.3", "", { "dependencies": { "@css-render/plugin-bem": "^0.15.14", "@css-render/vue3-ssr": "^0.15.14", "@types/lodash": "^4.17.20", "@types/lodash-es": "^4.17.12", "async-validator": "^4.2.5", "css-render": "^0.15.14", "csstype": "^3.1.3", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "evtd": "^0.2.4", "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "seemly": "^0.3.10", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", "vueuc": "^0.4.66" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], + + "rollup": ["rollup@4.64.12", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.12", "@rollup/rollup-android-arm64": "4.64.12", "@rollup/rollup-darwin-arm64": "4.64.12", "@rollup/rollup-darwin-x64": "4.64.12", "@rollup/rollup-freebsd-arm64": "4.64.12", "@rollup/rollup-freebsd-x64": "4.64.12", "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", "@rollup/rollup-linux-arm-musleabihf": "4.64.12", "@rollup/rollup-linux-arm64-gnu": "4.64.12", "@rollup/rollup-linux-arm64-musl": "4.64.12", "@rollup/rollup-linux-loong64-gnu": "4.64.12", "@rollup/rollup-linux-loong64-musl": "4.64.12", "@rollup/rollup-linux-ppc64-gnu": "4.64.12", "@rollup/rollup-linux-ppc64-musl": "4.64.12", "@rollup/rollup-linux-riscv64-gnu": "4.64.12", "@rollup/rollup-linux-riscv64-musl": "4.64.12", "@rollup/rollup-linux-s390x-gnu": "4.64.12", "@rollup/rollup-linux-x64-gnu": "4.64.12", "@rollup/rollup-linux-x64-musl": "4.64.12", "@rollup/rollup-openbsd-x64": "4.64.12", "@rollup/rollup-openharmony-arm64": "4.64.12", "@rollup/rollup-win32-arm64-msvc": "4.64.12", "@rollup/rollup-win32-ia32-msvc": "4.64.12", "@rollup/rollup-win32-x64-gnu": "4.64.12", "@rollup/rollup-win32-x64-msvc": "4.64.12", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ=="], + + "seemly": ["seemly@0.3.10", "", {}, "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "treemate": ["treemate@0.3.11", "", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], + + "vdirs": ["vdirs@0.1.8", "", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="], + + "vite": ["vite@7.3.7", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="], + + "vooks": ["vooks@0.2.12", "", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="], + + "vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="], + + "vue": ["vue@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="], + + "vue-tsc": ["vue-tsc@3.3.13", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="], + + "vueuc": ["vueuc@0.4.66", "", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="], + + "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "css-render/csstype": ["csstype@3.0.11", "", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="], + } +} diff --git a/docs/教材摘要/1-项目1到项目3前半.md b/docs/教材摘要/1-项目1到项目3前半.md new file mode 100644 index 0000000..56fa8fd --- /dev/null +++ b/docs/教材摘要/1-项目1到项目3前半.md @@ -0,0 +1,857 @@ +# 《物联网编程与应用 第2版》摘要 · 第1部分(PDF 第1–65页) + +> 页码换算:**书页 = PDF页 − 3**(PDF 4 = 书 p1)。扫描件里没有前言和二维码索引,只有目录。 +> 本部分结束于 **PDF 65 = 书 p62**,即**项目3 任务2 的【知识提炼】中间**(RadioButton“设置字体颜色”示例,表3-5 之后)。下一位读者从 PDF 66 / 书 p63 接着读(应为该示例的代码、CheckBox 知识点、能力拓展,以及书 p71 的项目3评价)。 +> 参考工程目录:`/home/xuyue/Projects/IOT/物联网编程与应用配套/物联网编程与应用/项目N/任务一|二|三`(文件夹用中文数字)。 + +## 0. 目录(PDF 1–3) + +| 项目 / 任务 | 书页 | PDF页 | +|---|---|---| +| 项目1 C#入门基础 | 1 | 4 | +|  任务1 C#引入 | 1 | 4 | +|  任务2 C#面向对象特性 | 13 | 16 | +|  项目评价 | 21 | 24 | +| 项目2 智慧校园系统——环境监测模块 | 23 | 26 | +|  任务1 LED显示屏的使用 | 23 | 26 | +|  任务2 实时采集温度、湿度和光照度数据 | 36 | 39 | +|  任务3 在LED显示屏中显示环境数据 | 42 | 45 | +|  项目评价 | 51 | 54 | +| 项目3 智慧校园系统——智慧路灯控制模块 | 53 | 56 | +|  任务1 使用ZigBee技术控制路灯开关 | 53 | 56 | +|  任务2 实现人体红外传感器和路灯联动 | 58 | 61 | +|  项目评价 | 71 | 74 | +| 项目4 入侵信息监测模块:任务1 采集红外对射入侵传感器数据 73;任务2 控制网络摄像机 78;任务3 控制报警灯 84;评价 89 | 73 | 76 | +| 项目5 灾情信息监测模块:任务1 采集火焰传感器数据 91;任务2 采集烟雾传感器数据 98;任务3 实时推送灾情信息 104;评价 110 | 91 | 94 | +| 项目6 数据存储模块:任务1 制作环境数据表 113;任务2 存储校园环境数据 119;任务3 查询校园安防数据 127;评价 131 | 113 | 116 | +| 项目7 情景控制模块:任务1 实现智慧教室 133;任务2 实现智慧寝室 141;任务3 实现智慧会议室 148;评价 156 | 133 | 136 | +| 项目8 智慧校园管理平台开发:任务1 制作管理平台的主界面 157;任务2 制作“校园环境”界面 159;任务3 实现“校园环境”数据采集及数据分析功能 164;任务4 实现“校园安防”功能模块 174;任务5 实现“校园安防”模块中显示历史图片的功能 181;评价 185 | 157 | 160 | +| 参考文献 | 187 | 190(超出187页的扫描件,可能缺页) | + +(注意:扫描件只有187页,所以书页 ≥ 185 的内容可能不在 PDF 里。) + +--- + +## 项目1 C#入门基础(书 p1–22 / PDF 4–25) + +**项目概述**:C# 是微软在 C 和 C++ 基础上衍生出的面向对象语言,基于 .NET。本项目先学 Python 示例程序,再逐步引入 C#,为后面的智慧校园开发做准备。 + +**学习目标** +- 了解 C# 与 Python 的语法区别。 +- 了解 C# 的语法规范。 +- 理解属性和 this 关键字。 +- 养成良好的代码编写规范。 +- 增强职业认识,提升职业素养,树立职业信心。 + +### 任务1 C#引入(书 p1–13 / PDF 4–16) + +**任务描述**:分别用 Python 和 C# 写一个登录程序,通过对比了解 C# 的命名空间、属性、this 等面向对象概念。 + +**任务实施** + +*1. Python 登录应用体验*(书 p2):控制台程序“登录”,用 `usernameLst = list()` 和 `pwdLst = list()` 保存用户。 +- `def ligon()`:用 input 读入用户名和密码,`if username in usernameLst and pwd in pwdLst` 时打印“登录成功”,否则打印“登录失败”。 +- `def addUser()`:读入新用户名并 append,读入新密码并 append,打印“添加用户成功!”。 +- `while(True)`:提示“请输入您要操作的内容(1.登录;2.新增用户,输入q退出程序)”;输入 '1' 调 `ligon()`,'2' 调 `addUser()`,'q' 执行 break。 +- 参考代码见 `项目1/任务一/python/登录.py`,内容与书一致(参考代码里少了 `q` 分支)。**模拟器如果要支持这一步,需要一个 Python 控制台(stdin/stdout)。** + +*2. C# 登录应用体验*(书 p2–7) +1. 新建 Windows 窗体应用程序,名称“登录”。 +2. 把 Form1 改名为 Login,用工具箱搭出“登录”界面(图1-3:两个标签、两个文本框,按钮“登录 / 取消 / 增加用户”)。 +3. 在“解决方案资源管理器”里选“添加 → 类”,新建 `PublicData`: + ```csharp + public class PublicData { + public static string[] SysUserName = new string[4];//存储系统用户名 + public static string[] SysPwd = new string[4];//存储系统用户密码 + public static int intNum { get; set; }//当前数据存储的用户数量 + public static int intUser { get; set; }//记录当前用户索引号 + } + ``` +4. 在 Login 中加全局变量 `AddUser addfrm = new AddUser();` 和 `public bool IsLogin = false;`。 +5. 写 `btnOk_Click`:遍历 `PublicData.SysUserName`;用户名和密码都匹配且不为空时,令 `IsLogin=true`,弹 `MessageBox.Show("登录成功,欢迎进入!","登录")`,并记下 `intUser = i`。书里接着是 `if (change) { this.Hide(); change = false; }`,然后 break。都不匹配时提示“无效的密码或用户名,请重试”,并执行 `txtPassword.Focus()`。 +6. 写 `btnCancel_Click`:`IsLogin=false; this.Close();` +7. 写 `btnAdd_Click`:`addfrm.Show();` +8. 添加 Windows 窗体 `addfrm`(图1-6“增加用户”界面)。 +9. 写 addfrm 的 `btnOk_Click`:当 `intNum<4` 且 `txtPass1.Text.Equals(txtPass2.Text) && txtPass2.Text != ""` 时,把 `txtNewUser1.Text` 和 `txtPass1.Text` 存进数组,`intNum++`,再 `this.Close()`。否则提示“用户名或密码错误,请重新输入”。数组满了提示“用户名已满不能增加”(标题都是“新增用户”)。 +10. 写 addfrm 的 `btnCancel_Click`:`this.Close();` + +**表1-1 “登录”界面控件的属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Login | 窗体 | Text | 登录 | | +| LblUsername | 标签 | Text | 用户名称 | | +| LblPwd | 标签 | Text | 密码 | | +| txtUserName | 文本框 | | | 输入用户名 | +| txtPwd | 文本框 | PasswordChar | * | 输入密码 | +| BtnOk | 按钮 | Text | 登录 | | +| BtnCancel | 按钮 | Text | 取消 | | +| BtnAdd | 按钮 | Text | 增加用户 | | + +**表1-2 “增加用户”界面控件的属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| addfrm | 窗体 | Text | 增加用户 | | +| LblUsername | 标签 | Text | 用户名 | | +| LblPwd | 标签 | Text | 密码 | | +| LblCheckPwd | 标签 | Text | 确认密码 | | +| txtUserName | 文本框 | | | 输入用户名 | +| txtPwd | 文本框 | PasswordChar | * | 输入密码 | +| txtCheckPwd | 文本框 | PasswordChar | * | 确认密码 | +| BtnOk | 按钮 | Text | 确定 | | +| BtnCancel | 按钮 | Text | 取消 | | + +**小贴士**(关于 VS IDE,模拟器需要有对应的界面) +- “工具箱”面板在 VS 左侧,按栏目列出控件。双击控件就能加到窗体上,也可以拖上去。 +- “属性”面板在 VS 右下方,用来设置控件属性,也用来管理控件事件。 + +**知识提炼**(书 p7–12) +1. **类**:类是创建对象的模板。用 `类修饰符 class 类名 { }` 声明,例如 `public class People { string name; int age; }`。静态类只能通过类名访问。继承写成 `class classChild : newClassName`。小贴士:继承时子类和基类之间必须用冒号。 +2. **对象**:用 `new 类名()` 创建。示例 `Student` 类有字段 name、无参构造函数(name="unknown")、带参构造函数和 `SetName` 方法;最后用 `MessageBox.Show(student1.name + " " + student2.name)` 输出。 +3. **命名空间**:用 `using 命名空间名称;` 引入。示例 `namespace TestN { class A { PrintMsg() → MessageBox.Show("物联网编程与应用") } }`,在 Form1_Load 中 `new A().PrintMsg()`。 +4. **注释**:单行用 `//`,多行用 `/* */`。示例是控制台程序,用到 `Console.WriteLine` 和 `Console.ReadLine`。 +5. **属性**:用 get/set 控制对私有字段的读写,至少要有其中一个。示例 Student 类:`School` 只读(默认值 "浙江**职业中学"),`Name` 只写,`Age` 可读写;`Display()` 用 Console.WriteLine 输出;Main 中用 `Console.Write("学校:"+stu1.School)`。 +6. **this 关键字**:this 代表当前实例,静态方法中不能用。示例 `this.Age = age; this.Name = name;`。 + +**能力拓展** + +*边想边做*(书 p12–13):制作登录界面,登录成功后跳转,用属性读写,并在界面上显示当前登录的用户名。 + +提示:根据前面学的知识补全下面的代码,再输入完整代码并调试。 +```csharp +public class Student +{ + private string name="admin";//设置默认用户 + private string pwd ="admin";//设置默认密码 + // 声明Name属性,可进行读写 + public string Name + { + set + { + name = value; + } + ____ //设置get属性 + { + ____ //返回name值 + } + } + ____ // 声明pwd属性,可进行读写 + { + ____ //设置set属性 + { + ____ //赋值pwd值 + } + get + { + return pwd; + } + } +} + +private void BtnLogin_Click(object sender, EventArgs e) +{ + if (txtUsername.Text != "" &&txtPwd.Text != "")//判断输入内容是否为空 + { + if (txtUsername.Text == stu.Name&&txtPwd.Text == stu.Pwd) + { + ____ //实例化main窗体 + ____ //将当前对象的stu对象赋值给main窗体对象的stu + ____ //使用Show方法显示main窗体 + ____ //使用Hide方法将当前窗体对象隐藏 + } + else + { + MessageBox.Show("登录失败,用户名或密码错误!", "登录"); + } + } + else + { + MessageBox.Show("用户名或密码为空!", "登录"); + } +} +``` +(参考答案大致为:`get` / `return name;` / `public string Pwd` / `set` / `pwd = value;` / `Main main = new Main();` / `main.stu = stu;` / `main.Show();` / `this.Hide();`。这里隐含要求:Login 窗体有字段 `Student stu = new Student();`,另有一个 `Main` 窗体,它有 public 字段 `stu`,并在界面上显示 `stu.Name`。书里没有给 Main 窗体的界面图或控件表。) + +*自主探究*(书 p13):把模块改成多用户登录系统,系统里有 10 个用户,要求: +1. 10 个用户能分别登录和退出。 +2. 用对象存储用户数据,并用属性控制字段的读写。 + +### 任务2 C#面向对象特性(书 p13–21 / PDF 16–24) + +**任务描述**:面向对象有封装、继承、多态三个特性。完成本任务后,学生能初步理解 C# 的装箱与拆箱、多态性,以及 List、Dictionary 的用法。 + +**任务实施** +1. 新建 Windows 窗体应用程序“C#基础”(存放在 项目1\任务2)。 +2. 按图1-7 搭界面:groupBox1“简易计算器”里放 txtNum1、运算符下拉框、txtNum2、“=”按钮、结果框;groupBox2“类的多态性”里放一个多行文本框和“显示”按钮。 +3. 写 `public Object Result(Object a, Object b, String type)`:先拆箱成 float,再 switch 处理 + - * /,default 返回 0,最后 `return (Object)result;`(装箱)。 +4. “=”按钮 `btnCalculate_Click`:两个文本框都不为空时,`float.Parse` 两个数,`float res = (float)Result(a, b, cmboxType.Text)`,再令 `txtResult.Text = res.ToString()`。 +5. 新建 `People` 类:`public virtual void Speak(TextBox Txtbox, string name) { Txtbox.Text += name; }` +6. 新建 `Chinese : People`,override 后执行 `base.Speak(Txtbox, name+"说的是汉语!\r\n")`。 +7. 新建 `American : People`,override 后执行 `base.Speak(Txtbox, name+"说的是英语!\r\n")`。 +8. 写“显示”按钮 `btnDisplay_Click`(书上第8步误写成“双击‘=’按钮”):先建 `Dictionary`,内容为 {1:"张三", 2:"李四", 3:"Mary", 4:"Jimmy"};再建 `List`,放 2 个 Chinese 和 2 个 American;最后遍历执行 `peoplelist[i].Speak(this.txtDispaly, dicName[i+1])`。 +9. 运行:例如输入 1、+、2 后得 3;点“显示”后文本框输出“张三说的是汉语!/李四说的是汉语!/Mary说的是英语!/Jimmy说的是英语!”(图1-8)。 + +**表1-3 “C#入门基础”界面控件的属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | C#入门基础 | | +| groupBox1 | 组合框 | Text | 简易计算器 | | +| txtNum1 | 文本框 | | | 输入数字1 | +| txtNum2 | 文本框 | | | 输入数字2 | +| cmboxType | 下拉列表框 | | | 选择运算符 | +| btnCalculate | 按钮 | Text | = | 计算结果 | +| txtResult | 文本框 | ReadOnly | True | 显示计算结果 | +| groupBox2 | 组合框 | Text | 类的多态性 | | +| txtDispaly | 文本框 | | | | +| btnDisplay | 按钮 | Text | 显示 | | + +(书中没写,但模板必须有:`cmboxType.Items` = "+","-","*","/",`txtDispaly.Multiline = true`。参考工程里就是这样设置的。) + +**知识提炼**(书 p16–20,示例大多是**控制台程序**,用 Console.WriteLine / Console.Read) +1. **装箱和拆箱**:装箱把值类型转成引用类型(`Object obj = str;`),拆箱把引用类型转回值类型(`string s = (string)obj;`)。示例里用 `Console.WriteLine("...{0}...{1}", str, obj)` 做格式化输出。 +2. **多态**: + - 重载(overload):示例 `ClaArea.Area(double r)` 返回 `Math.PI*r*r`,`Area(double x, double y)` 返回 `x*y`。`Area(4)` 输出 50.26482,`Area(3,4)` 输出 12。 + - 虚方法(virtual)与重写(override):`MyVirtualFun.Fun()` 输出“我是父类虚方法”,`OverrideFunction : MyVirtualFun` 重写后输出“我是子类方法”。 +3. **List\**:表1-4 常见属性有 Count 和 Capacity(Capacity ≥ Count)。表1-5 常见方法有 Add、AddRange、Clear、Contains、CopyTo(T)、Find、IndexOf(T)、Insert、InsertRange、LastIndexOf(T)、Remove、Reverse()、Sort()、TrimExcess。示例依次调用:Add("A")、AddRange({"B","C","D"})、Capacity、Count、Contains("D")、Insert(2,"E")、CopyTo(array[10])、Remove("B")、Sort()、TrimExcess()、Clear()。 +4. **Dictionary 类**:按键查找值,键必须唯一。格式 `Dictionary DemoDic = new Dictionary();`。示例用到 Add、按索引器取值 `Dic[2]`、ContainsKey(3)、Remove(2)、ContainsValue("张三")。 + +**能力拓展** + +*边想边做*(书 p20–21):“任务实施”中已经用过虚方法和 List。这里要定义形状父类,并用圆类实现其虚方法。按图1-9 创建界面:窗体标题“面积计算”;标签“请输入圆的半径:”加文本框 txtCircleR 和“添加”按钮;“显示面积”按钮;下方一个大的多行文本框 txtArea。按下面的提示实现功能。 + +1) 定义一个 Shape 类: +```csharp +class Shape +{ + ____ //定义虚方法Area,无返回值,参数为TextBox类型 + { + } +} +``` +2) 定义一个 Circle 类,继承 Shape: +```csharp +class Circle: Shape +{ + double area; + public Circle(double R) + { + ____ //实现构造函数,将R变量赋值给当前变量r + } + ____ //重写父类Area方法 + { + area= Math.PI____; //实现面积计算 + txtArea.Text =txtArea.Text+ area.ToString()+"\r\n"; + } +} +``` +3) 在“添加”按钮的 Click 事件里按提示补全代码: +```csharp +private void btnAdd_Click(object sender, EventArgs e) +{ + Shape CirTemp; + if (txtCircleR.Text != "") + { + ____; //实例化Circle类 + ____; //将Shape对象加入list列表 + txtCircleR.Text = ""; + } + else + { + MessageBox.Show("输入半径大小"); + } +} +``` +(书中代码有缺陷:Circle 没有声明字段 `r`,需要补 `double r;`。Area 方法的参数名应为 `txtArea`。“显示面积”按钮的代码书里没给,应为遍历 list 并调用 `Area(txtArea)`。窗体还需要字段 `List list = new List();`。) + +*自主探究*(书 p21):定义形状类,在其中封装 X 和 Y 两个变量,并定义一个虚方法;再定义一个继承形状类的矩形类,实现该虚方法,输出 X×Y 的值。用 List 存 5 个矩形对象,循环输出矩形面积。 + +### 项目1 项目评价(书 p21–22 / PDF 24–25) + +**1. 项目评价指标**(表中没有分值) + +| 任务 | 评价指标 | 具体要求 | +|---|---|---| +| 任务1 | 类与对象的概念 | 学会创建自定义类、实例化对象,理解类与对象 | +| 任务1 | 窗体界面 | 窗体控件正确,布局美观 | +| 任务1 | 单击事件 | 按钮单击事件的正确使用 | +| 任务1 | 属性、this | 理解属性、this关键字 | +| 任务2 | 拆箱与装箱 | 能正确使用拆箱与装箱 | +| 任务2 | 多态性 | 重载、虚函数、重写的正确使用 | +| 任务2 | List | 了解List的使用 | +| 任务2 | Dictionary | 了解Dictionary的使用 | +| 职业素养 | 代码书写规范 | 应该遵从结构化的要求,采用缩进的格式 | +| 职业素养 | 注释书写规范 | 做到清晰、准确地描述内容。对于程序中复杂的部分必须有注释加以说明。注释量要适中,避免过多或过少导致阅读困难 | +| 职业素养 | 变量命名规范 | 选择有意义的英文进行命名,使变量名能清楚地表达其含义。尽量少使用人名、地名和汉语拼音。应使用那些约定俗成的缩写。多个单词组成的变量名,每个单词的首字母应该大写 | + +**2. 个人学习总结**:只有两块空白,“项目学习情况:”和“心得与反思:”,供学生填写。 + +### 项目1:书与参考工程的差异,以及模拟器要注意的点 +- **任务1 控件命名不一致**。参考工程 `项目1/任务一/C#/frmlogin`: + - 密码框叫 `txtPassword`(表1-1 写 txtPwd,但书上代码用的是 txtPassword)。 + - 标签是 `label1/label2`,Text 为“用户名称(&U)”和“密码(&P)”(带助记符 &)。 + - btnOk 的 Text 是“确定”(书上是“登录”)。 + - Login 的 Load 事件 `frmExam5_8_Load` 预置了用户 `admin/admin`,书中没有这一步。没有它,第一次运行时没有可登录的用户。 + - “增加用户”窗体类名是 `AddUser`(Name 为 "frmExam_5_82"),书上叫 addfrm。文本框是 `txtNewUser1/txtPass1/txtPass2`,与表1-2 的 txtUserName/txtPwd/txtCheckPwd 不一致,但和书上代码一致。**模板应以代码中用到的名字为准。** +- **书中 `btnOk_Click` 用了未声明的变量 `change`**,属于书的错误。参考工程没有这个变量,另有一个 `btnChange_Click`,它先 `btnOk.PerformClick()`,登录成功再 `this.Hide()`。但设计器里没有 btnChange 按钮,这段是死代码。 +- 任务1 需要**多窗体**支持:`new AddUser()`、`Show()`、`Hide()`、`Close()`;拓展练习还要跨窗体传对象(main.stu)。也要支持 `static` 类成员和自动属性。 +- 任务2 参考工程中按钮叫 `BtnCalculate`(B 大写),handler 是 `BtnCalculate_Click`;书上是 btnCalculate。窗体 Text 为“c#入门基础”。 +- 知识提炼的示例全是**控制台程序**(Console.WriteLine/Write/ReadLine/Read 和 `{0}` 格式化)。平台如果想让学生运行这些示例,需要一个控制台输出面板。 +- 任务1 第一部分是 **Python** 控制台程序。 +- 书中把运算符 "-" 排成了 "–"(en dash),抄写时要改回 ASCII 减号。 + +--- + +## 项目2 智慧校园系统——环境监测模块(书 p23–52 / PDF 26–55) + +**项目概述**:LED 显示屏是校园的信息发布平台。本项目用温湿度传感器和光敏电阻传感器采集数据,并显示在校园 LED 显示屏上。 + +**学习目标** +- 掌握串口通信的基本概念和工作原理。 +- 了解 LED 显示屏的工作原理及数据推送方法。 +- 掌握传感器的工作原理及数据采集方法。 +- 熟悉温湿度传感器、光敏电阻传感器的工作原理。 +- 理解模拟量采集器 ADAM4017 的功能及使用方法。 +- 遵循代码编写规范,养成正确的变量命名与注释习惯,培养思维逻辑能力。 +- 增强职业认识,提升职业素养,树立职业信心。 + +### 任务1 LED显示屏的使用(书 p23–36 / PDF 26–39) + +**任务描述**:模拟智慧校园信息发布系统,用 C# 写程序通过串口把信息发送到 LED 显示屏。学生要学会向 LED 显示屏发送信息、使用 groupBox 控件,并了解 C# 的错误处理方法。 + +**任务实施** +1. 把 LED 显示屏通过串口接到 PC(没有硬件的读者跳过)。 +2. 新建 Windows 窗体应用程序“LED显示屏”(项目2\任务1)。 +3. 从配套资源中把 `DeviceCtrlLibrary.dll` 和 “API” 目录下的全部 dll 复制到 `bin\Debug`。API 目录有 ComLibrary.dll、ICS.Acquisition.dll、ICS.Common.dll、ICS.Models.dll、IPCameraDll.dll、LEDLibrary.dll、MyCamer.dll。 +4. 添加引用 `DeviceCtrlLibrary.dll`。**没有硬件的读者添加仿真动态库文件,不用添加 API 文件夹里的 dll。** +5. 把自定义控件加入工具箱:右击工具箱“常规”,选“选择项”,点“浏览”,加载 `WinFormsDeviceControlLibrary.dll`。加载后“常规”下出现 4 个控件:**Alarm、Lamp、LED、ShowMsg**(图2-5)。 +6. 按图2-6 搭界面:上方是大块 LED 控件;下方 groupBox“LED控制”中有“串口”下拉框、“打开”按钮、“推送LED信息”文本框和“推送”按钮。 +7. 添加 using:`System.IO.Ports`、`DeviceCtrlLibrary`,以及 `WinFormsDeviceControlLibrary`(注释写“引用外部窗体类所在的命名空间”)。 +8. `btn_Open_Click`:`DeviceCtrlHelper.ledHelper.OpenLedCom(com_Port.Text);` +9. `btn_Send_Click`:`string res = DeviceCtrlHelper.ledHelper.SendLedString(txt_SendText.Text);` 如果 `res.IndexOf("Error") >= 0`,执行 `MessageBox.Show(res)`,否则执行 `led.setLedText(res)`。 +10. `com_Port_Click`:先 `com_Port.Items.Clear()`,再 `foreach (string item in SerialPort.GetPortNames()) com_Port.Items.Add(item);` +11. 运行:选串口(图中是 COM2),输入“欢迎学习物联网”,先点“打开”再点“推送”。仿真环境下 LED 控件会大字显示这段文字(图2-7)。 + +**表2-1 “LED显示屏”窗体中控件的属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | LED显示屏 | | +| LED | 自定义控件 | | | 显示LED推送的信息 | +| groupBox1 | 组合框 | Text | LED控制 | 将功能控件放在同一个框里 | +| Label1 | 标签 | Text | 串口 | | +| Label2 | 标签 | Text | 推送LED信息 | | +| btn_Open | 按钮 | Text | 打开 | 打开(关闭)串口 | +| btn_Send | 按钮 | Text | 推送 | 向LED显示屏发送信息 | +| com_Port | 下拉列表框 | | | 显示串口号 | +| txt_SendText | 文本框 | | | 输入要发送的信息 | + +**小贴士(仿真相关)** +- 书 p27 原文:“本项目使用的是硬件测试功能,若要使用仿真功能,请将资源文件下的 DeviceCtrlLibrary.dll 文件复制到 D:\物联网编程与应用\项目2\任务1\LED显示屏\LED显示屏\bin\Debug 文件夹下。”也就是说,**仿真版和硬件版用的是同名的 DeviceCtrlLibrary.dll,API 完全相同**,只是换一个 dll。 +- 结果判断约定:SendLedString 失败时返回带 "Error" 的字符串,代码用 `IndexOf("Error")` 判断。 + +**知识提炼**(书 p28–34) +1. **串口通信及串口波特率**:串口即 Serial Port / COM 接口,按位顺序传输。接口标准有 RS-232C、RS-422、RS-423、RS-485,涉及 DTE 和 DCE。波特率例子:9600bit/s 约等于每秒 1200 字节。两端波特率必须一致,否则会出现乱码。 +2. **SerialPort 类**: + - 表2-2 常见属性:BaseStream、BaudRate、BytesToRead、BytesToWrite、CDHolding、CtsHolding、DataBits、Encoding、ParityReplace、ReadBufferSize、ReadTimeout、ReceivedBytesThreshold、StopBits、WriteBufferSize、WriteTimeout、PortName。 + - 表2-3 常见方法:Close、Open、Read、ReadByte、ReadChar、ReadLine、ReadTo、Write、WriteLine。 + - 发送示例:`new SerialPort()`,PortName="COM1",BaudRate=9600,Open,用 `Encoding.Unicode.GetBytes("ABC")` 转字节再转 Base64,WriteLine 后 Close。接收示例:ReadLine 后 `Convert.FromBase64String`,再 `Encoding.Unicode.GetString`,最后 MessageBox 显示。 +3. **C# 基本数据类型**(表2-4):decimal、bool、char、byte、sbyte、short、int、long、ushort、uint、ulong、float、double、string,表中列出了各类型的位数和范围。 +4. **变量**:区分大小写;以字母开头;只能由字母、数字、下划线和 @ 组成;不能与关键字或库函数同名(书中举 `char use;` 不合法、`char @use;` 合法,其实 use 不是 C# 关键字,这是书的错误)。作用域分字段、局部变量、循环变量。示例:全局 `int i=5`,`BtnShow_Click` 中弹出全局 i,再调 `showi()` 弹出局部 `i=1`。 +5. **运算符**: + - 表2-5 算术:+ - * / % ++ --。示例 a=8, b=3:a*b=24,a/b=2,a%b=2。 + - 表2-6 比较:== != > < >= <=。 + - 表2-7 逻辑:&& || !。 + - 表2-8 赋值:= += -= *= /= %=。 +6. **错误处理 try-catch**:除以 0 时程序会弹出“未经处理的异常…尝试除以零”的对话框(图2-8),加 try-catch 后改为 `MessageBox.Show("程序出现不可预知的错误:"+ex.Message)`。(书中误写为 `Catch(EventArgs ex)`,应为 `catch (Exception ex)`。) +7. **动态链接库文件**:DeviceCtrlLibrary.dll 和 WinFormsDeviceControlLibrary.dll 是编者提供的 dll,功能见“设备控制类库使用说明.doc”,使用前必须先加载、引用。 + +**能力拓展** + +*边想边做*(书 p34–36):做一个在 LED 上显示计算结果的小应用。参考界面图2-9,窗体标题“LED显示计算结果”: +- 上方是 LED 控件。 +- 中部 groupBox“简单计算器”:`[textBox1] + [textBox2] = [textBox3]` 和“计 算”按钮。 +- 下部 groupBox“打开串口”:“串口”下拉框和“打开串口”按钮。 + +提示1:补全代码后,双击“计算”按钮,把代码输入代码编写窗口。 +```csharp +private void btn_Calculation_Click(object sender, EventArgs e) +{ + int add_1 = 0; //定义变量 + int ____; //定义变量 + int ____; //定义变量 + int ____; //定义变量 + string right = DeviceCtrlHelper.ledHelper.SendLedString("你答对了!"); + string erro = DeviceCtrlHelper.ledHelper.SendLedString("你答错了!"); + if (IsOpen) + { + if (textBox1.Text != "" && textBox2.Text != "" && textBox2.Text != "") + { + try + { + //将textBox1中的文本转换成数值 + add_1 = int.Parse(textBox1.Text); + add_2 = ____; + add_3 = ____; + counter = ____;//求和 + if (counter == add_3) + { + if (right.IndexOf("Error") >= 0) + { + MessageBox.Show(right); //出错提示 + } + else + { + led.setLedText(right); //LED显示屏输出信息 + MessageBox.Show("你答对了!"); + } + } + else + { + if (erro.IndexOf("Error") >= 0) + { + ____; //出错提示 + } + else + { + ____; //LED显示屏输出信息 + MessageBox.Show("你答错了!"); + } + } + } + catch (Exception) + { + //输入格式出错提醒 + MessageBox.Show("请正确的格式。", "提醒"); + } + } + else + { + //没有输入提醒 + MessageBox.Show("请你输入!,", "提醒"); + } + } + else + { + //没有设置串口提醒 + MessageBox.Show("请设置好串口", "警告"); + } +} +``` +(几点说明:第三个判断原文就是重复的 `textBox2.Text != ""`,应为 textBox3;变量名 add_2、add_3、counter 由后文推出;`IsOpen` 需要“打开串口”按钮来设置,书中没给这段代码;right/erro 在判断 IsOpen **之前**就调用了 SendLedString,串口未打开时仿真库可能返回 Error,也可能抛异常,模拟器需要定义这种行为。) + +提示2:btn_Calculation 单击事件流程图(图2-10)的文字描述: +- 开始,初始化变量,判断 IsOpen 是否为 true。 + - 否:提示信息“串口参数没有设置好”,结束。 + - 是:判断三个文本框是否都不为空。 + - 否:提示信息“请你输入信息”,结束。 + - 是:把三个文本框的文本转成数值,求和,再判断求和结果与输入的数据是否相等。 + - 正确:判断串口通信是否正常。否则提示“串口出错信息”;正常则向 LED 屏输出正确信息,并弹出程序提示“你答对了”。 + - 不正确:判断串口通信是否正常。否则提示“串口出错信息”;正常则向 LED 屏输出答错信息,并弹出程序提示“你答错了”。 +- 所有分支都到“结束”。 + +*自主探究*(书 p36):给该模块增加乘法和除法计算功能。 + +### 任务2 实时采集温度、湿度和光照度数据(书 p36–42 / PDF 39–45) + +**任务描述**:用 C# 写程序,把传感器采集的数据显示在计算机上。学生要学会使用 Label 标签和 Button 按钮。(有“扫码看视频”。) + +**任务实施** +1. 把模拟量采集器 ADAM4017 接到 PC 串口(没有的跳过)。 +2. 新建 Windows 窗体应用程序“模拟量采集”(项目2\任务2)。 +3. 把 DeviceCtrlLibrary.dll 和 API 目录下的全部 dll 复制到 bin\Debug。 +4. 添加引用 DeviceCtrlLibrary.dll(没有硬件的读者添加仿真 dll,不加 API)。 +5. 按图2-11 搭界面:左边 groupBox“数据显示”里有光照度、温度、湿度三组“标签+文本框”,右侧一个竖长的“获取”按钮;下方 groupBox“配置”里有“串口”下拉框和“开启”按钮。 +6. 添加 using:`System.IO.Ports`、`DeviceCtrlLibrary`、`DeviceCtrlLibrary.Model`。 +7. 定义全局变量 `bool IsOpen=false;` 和 `ADAM4017ResModel ResModel;`。 +8. `frm_analog_Load`(窗体 Load 事件):清空 com_port,把 `SerialPort.GetPortNames()` 的结果全部加进去。 +9. `btn_Open_Click`:如果 `!IsOpen`,执行 `DeviceCtrlHelper.adam4017Helper.Open(com_port.Text)`,按钮文字改为“关闭”,`IsOpen=true`;否则执行 `adam4017Helper.Close()`,按钮文字改为“开启”,`IsOpen=false`。 +10. `btn_get_Click`:`ResModel = DeviceCtrlHelper.adam4017Helper.SetData();`,然后令 `txt_light.Text = ResModel.lightValue`、`txt_humidity.Text = ResModel.humidityValue`、`txt_temperature.Text = ResModel.temperatureValue`。 + +**小贴士**:ADAM4017 是研华科技的 16 位、8 通道模拟量输入模块,可以采集电压、电流等模拟量输入信号。 + +**表2-9 “模拟量采集”界面控件的属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| frm_analog | 窗体 | Text | 模拟量采集 | | +| grp_show | 组合框 | Text | 数据显示 | | +| grp_config | 组合框 | Text | 配置 | | +| txt_light | 文本框 | | | 输入/显示光照度值 | +| txt_temperature | 文本框 | | | 输入/显示温度值 | +| txt_humidity | 文本框 | | | 输入/显示湿度值 | +| btn_get | 按钮 | Text | 获取 | 控制数据获取 | +| btn_Open | 按钮 | Text | 开启 | 打开串口 | +| com_port | 下拉列表框 | | | 显示串口 | +| lbl_light | 标签 | Text | 光照度 | | +| lbl_temperature | 标签 | Text | 温度 | | +| lbl_humidity | 标签 | Text | 湿度 | | + +**知识提炼**(书 p39–41) +1. **Label 标签**:显示用户不能编辑的文本或图像,不能获得焦点。表2-10 常见属性有 Name、Text、Enabled、AutoSize、BackColor(可设为 Color.Transparent)、BorderStyle(None / FixedSingle / Fixed3D)、Visible、ForeColor、Font、Image、TextAlign(仅在 AutoSize=false 时有效)。 +2. **Button 按钮**: + - 表2-11 常见属性:Name、Text、Image、FlatStyle、DialogResult(OK、Cancel、Abort、Retry、Ignore、Yes、No)、Enabled、Visible、Font、BackColor、Image。 + - 表2-12 常见事件(书中表头误写为“方法”):Click、MouseDown、MouseUp。 + - 窗体的 **AcceptButton**(按 Enter 相当于点击该按钮)和 **CancelButton**(按 Esc 相当于点击该按钮)。 + - 示例“Label显示示例”(项目2\任务2)见表2-13:Form1 的 Text 为“Label显示”;btnDisplay 是按钮,Text 为“显示”;label1 是标签。代码 `btnDisplay_Click` 中执行 `this.label1.Text = "显示内容";`,运行后 label1 显示“显示内容”(图2-12、2-13)。 + +**表2-13 “Label显示”界面控件的属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | Label显示 | | +| btnDisplay | 按钮 | Text | 显示 | | +| label1 | 标签 | | | | + +**能力拓展** + +*边想边做*(书 p41–42) +1) 用标签和文本框实现图片的显示与隐藏(图2-14)。点“显示”按钮显示图片,点“隐藏”按钮隐藏图片,图片素材在资源文件“项目2\图片\”中。界面:窗体标题“练习”,大图区域(企鹅图片),下方“显示”和“隐藏”两个按钮。实现上可用 Label.Image 加 Visible,或用 PictureBox。**配套资源里没有“项目2\图片”文件夹,平台需要自备一张图片。** +2) 任务实施中已经能获取光照度、温度和湿度。要让模块实时获取这些环境值,可以用时间控件。按下面的提示实现: + - 提示1:按表2-14 设置时间控件的关键属性(值由学生填)。 + + | 属性 | 值 | + |---|---| + | 名称 | timer1 | + | Interval | ? | + | Enabled | ? | + + 小贴士:Interval 表示多长时间执行一次 timer1_Tick 事件,单位是 ms,这里设多少比较合理? + - 提示2:补全下面的代码。 + ```csharp + private void btn_get_Click(object sender,EventArgs e) + { + if(btn_get.Text=="获取") + { + timer1.Start(); //启动时间控件 + btn_get.Text="____"; + } + else + { + timer1.____; + btn_get.Text="____"; + } + } + ``` + - 提示3:参考“任务实施”中的代码,为 timer1 编写代码(即在 timer1_Tick 中调用 SetData 并刷新 3 个文本框)。 + +*自主探究*(书 p42):为模块增加以下功能: +1. 实时采集大气压的数值。 +2. 每 2s 更新显示一次温度值、湿度值、光照度值和大气压值。 + +(仿真库的 `ADAM4017ResModel` 确实有 `airPressureValue`,还有 airQualityValue、CO2Value、windValue。`adam4017Helper.Open` 的签名为 `Open(string PortName, int Temperature_Port=0, int Humidity_Port=1, int Light_Port=2, int AirPressure_Port=3, int AirQuality_Port=4, int CO2_Port=5, int Wind_Port=6)`。所以这个练习在模拟器里可以做,模拟器应提供大气压数据。) + +### 任务3 在LED显示屏中显示环境数据(书 p42–51 / PDF 45–54) + +**任务描述**:结合 LED 显示屏推送,把温湿度传感器和光敏传感器的数据推送到 LED 屏显示。学生要学会使用 comboBox 和 TextBox。(有“扫码看视频”。) + +**任务实施** +1. 把 ADAM4017 和 LED 显示屏接到串口服务器,再把串口服务器接到 PC。 +2. 新建项目“LED推送模拟量”(项目2\任务3)。 +3. 把自定义控件 WinFormsDeviceControlLibrary.dll 加入工具箱。 +4. 复制 DeviceCtrlLibrary.dll 和 API 下的 dll 到 bin\Debug(没有硬件的读者用仿真 dll,不加 API)。 +5. 添加引用 DeviceCtrlLibrary.dll。 +6. 按图2-15 搭界面:顶部是黑色 LED 控件;左边 groupBox“数据显示”里有光照度、温度、湿度三个文本框和竖长的“获取”按钮;右边 groupBox“配置”里有“模拟量串口”下拉框、“LED串口”下拉框和“开启”按钮。 +7. 添加 using:System.IO.Ports、DeviceCtrlLibrary、DeviceCtrlLibrary.Model。 +8. 定义全局变量:`bool IsOpen=false;`、`ADAM4017ResModel ResModel;`、`String LED_MSG="";`。 +9. `frm_LED_Load`:清空 com_adamPort 和 com_ledPort,用 foreach 把 GetPortNames 的结果同时加入两个下拉框。 +10. `btn_Open_Click`:如果 `!IsOpen`,执行 `adam4017Helper.Open(com_adamPort.Text)` 和 `ledHelper.OpenLedCom(com_ledPort.Text)`,`btn_open.Text="关闭"`,`IsOpen=true`;否则执行 `adam4017Helper.Close()` 和 **`DeviceCtrlHelper.ledHelper.Close()`**,`btn_open.Text="开启"`,`IsOpen=false`。 +11. `btn_get_Click`:先 `LED_MSG=""`,SetData 后填好 3 个文本框,再拼接 + `LED_MSG += ("当前环境信息"+" "+"光照度:"+ResModel.lightValue+"lx温度:"+ResModel.temperatureValue+"℃湿度:"+ResModel.humidityValue+"%");` + 然后 `String Res = DeviceCtrlHelper.ledHelper.SendLedString(LED_MSG);`,`if(!(Res.IndexOf("Error")>=0)) led.setLedText(Res);` + +**表2-15 “LED推送模拟量”界面控件的属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| frm_LED | 窗体 | Text | LED推送模拟量 | | +| grp_show | 组合框 | Text | 数据显示 | | +| grp_config | 组合框 | Text | 配置 | | +| txt_light | 文本框 | | | 输入/显示光照度值 | +| txt_temperature | 文本框 | | | 输入/显示温度值 | +| txt_humidity | 文本框 | | | 输入/显示湿度值 | +| btn_get | 按钮 | Text | 获取 | 控制数据的获取 | +| btn_Open | 按钮 | Text | 开启 | 打开串口 | +| com_adamPort | 下拉列表框 | | | 显示串口 | +| com_ledPort | 下拉列表框 | | | 显示串口 | + +(表中没有列 LED 控件,按代码它应叫 `led`;界面上也有光照度、温度、湿度和“模拟量串口”“LED串口”这些标签,表中同样没列。) + +**知识提炼**(书 p45–49) +1. **comboBox 下拉列表框**:点下三角或按 F4 展开,展开或折叠时触发 DropDownOpened / DropDownClosed。可以用 Items 静态添加,也可以用 `Items.Add` 动态添加,用 `Items.Clear` 清空。Text 属性是当前选中的内容,DropDownStyle 控制样式。 + - 示例“下拉设置字体样式”(项目2\任务3),图2-16 窗体“字体样式”:标签“字体样式”加下拉框,下方 label2 显示“字体[宋体]”。 + - `Form1_Load`:`String[] type = {"宋体","黑体","楷体"}`,foreach 后执行 `this.comboBox1.Items.Add(items)`,再 `SelectedIndex = 0`。 + - `comboBox1_SelectedIndexChanged`(书上写“找到 Click 事件”,实际是 SelectedIndexChanged):switch 0/1/2 时执行 `this.label2.Font = new Font(comboBox1.Items[i].ToString(), 12); this.label2.Text = "字体["+ comboBox1.Items[i].ToString()+"]";` + - 运行效果见图2-17,例如“字体[黑体]”。 + + **表2-16 各控件相关属性及命名** + + | 控件名称 | 控件类型 | 属性名 | 属性值 | + |---|---|---|---| + | Form1 | 窗体 | Text | 字体样式 | + | label1 | 标签 | Text | 字体样式 | + | label2 | 标签 | Text | 字体[字体名] | + | comboBox1 | 下拉列表框 | Items | “宋体”“黑体”“楷体” | + + (表里已经静态设了 Items,Form1_Load 又动态加了一遍,会出现重复项,属于书的错误。模拟器要能渲染 宋体、黑体、楷体 字体,即 `new Font(name, size)`。) +2. **TextBox 文本框**: + - 表2-17 常见属性:Text(默认最多 2048 字符,MultiLine 时 32KB)、MaxLength、MultiLine、HideSelection、ReadOnly、PasswordChar、ScrollBars、SelectionLength、SelectionStart、SelectedText、Lines、Modified、TextLength、WordWrap。 + - 表2-18 常见方法:AppendText、Clear、Focus、Copy、Cut、Paste、Undo、ClearUndo、Select(start,length)、SelectAll。 + - 表2-19 常见事件:GotFocus、LostFocus、TextChanged、Click。 + - 示例“添加用户”(项目2\任务3),图2-18:账号、密码、“添加用户”按钮和多行“用户信息”框。`Form1_Load` 中执行 `this.tbox_Password.UseSystemPasswordChar = true;`。`btn_Add_Click` 中执行 `this.txtUserMessage.Text += "账号:"+user+"\t"+"密码:"+pass+"\r\n";`,书中 **user/pass 没有定义**,应先取 `tbox_Username.Text` 和 `tbox_Password.Text`。运行效果见图2-19,例如“账号:test 密码:123”。 + + **表2-20 各控件相关属性及命名** + + | 控件名称 | 控件类型 | 属性名 | 属性值 | + |---|---|---|---| + | Form1 | 窗体 | Text | 添加用户 | + | label1 | 标签 | Text | 账号: | + | label2 | 标签 | Text | 密码: | + | label3 | 标签 | Text | 用户信息: | + | tbox_Username | 文本框 | | | + | tbox_Password | 文本框 | UseSystemPasswordChar | true | + | txtUserMessage | 文本框 | | | + | btn_Add | 按钮 | Text | 添加用户 | + + (txtUserMessage 应设 Multiline=true。) + +**能力拓展** + +*边想边做*(书 p49–50):制作用户登录界面,点“登录”按钮后用 messagebox 显示用户名和密码(图2-20:窗体“登录界面”,标题“用户登录界面”,用户名和密码各一个文本框,一个“登录”按钮)。提示:设置密码文本框的相关属性。 + +**表2-21 各控件的属性**(书中原表就有问题,照录) + +| 控件名称 | 控件类型 | 属性名 | 属性值 | +|---|---|---|---| +| Form1 | 窗体 | Text | 登录界面 | +| txtUsername | 文本框 | Text | 用户名: | +| txtPassword | 文本框 | Text | 密码: | +| label_title | 标签 | Text | 用户登录界面 | +| tbox_Username | 文本框 | | | +| btnLogin | 按钮 | Text | 登录 | + +(txtUsername 和 txtPassword 实际应是**标签**;表中漏了 `tbox_Password`(文本框),代码里用到了它。) + +补充代码: +```csharp +private void btnLogin_Click(object sender, EventArgs e) +{ + MessageBox.Show("用户名:"+this.tbox_Username.Text+"\r\n"+"密码:"+this.tbox_Password.Text); +} +private void Form1_Load(object sender, EventArgs e) +{ + this.tbox_Password.UseSystemPasswordChar = true; + //设置文本框的UseSystemPasswordChar属性为True +} +``` +运行效果见图2-21:输入 test 和 ***,弹窗显示“用户名:test / 密码:123”。 + +*自主探究*(书 p50–51):增加功能:用户名为“admin”且密码为“123456”时提示“登录成功”,否则提示“登录失败”(图2-22、图2-23)。注意图中输入的是 user1,看起来与题目要求不一致。 + +### 项目2 项目评价(书 p51–52 / PDF 54–55) + +**1. 项目评价指标**(没有分值) + +| 任务 | 评价指标 | 具体要求 | +|---|---|---| +| 任务1 | LED显示屏的使用 | 按照教程完成界面和功能 | +| 任务1 | 制作LED显示计算结果 | 完成界面设计 | +| 任务1 | 制作LED显示计算结果 | 完成程序功能设计 | +| 任务2 | 窗体界面 | 界面布局合理,美观大方,符合使用者习惯 | +| 任务2 | 功能实现 | 环境值能正确获取 | +| 任务2 | 功能实现 | 环境值能正确显示 | +| 任务2 | 功能实现 | 实时时间控制正常 | +| 任务3 | 窗体界面 | 界面布局合理,美观大方,符合使用者习惯 | +| 任务3 | 功能实现 | LED屏能正常显示 | +| 任务3 | 功能实现 | 环境值能正确推送到LED屏 | +| 任务3 | 功能实现 | 能实时推送信息 | +| 职业素养 | 注释书写规范 | 做到清晰、准确地描述内容。对于程序中复杂的部分必须有注释加以说明。注释量要适中,避免过多或过少导致阅读困难 | +| 职业素养 | 变量命名规范 | 选择有意义的英文进行命名,使变量名能清楚地表达其含义。尽量少使用人名、地名和汉语拼音。应使用那些约定俗成的缩写。多个单词组成的变量名,每个单词的首字母应该大写 | +| 职业素养 | 界面常用控件命名约定 | 窗体控件命名以控件名称缩写前3个字母开头,以具有代表含义的词来命名,其形式为:控件缩写(前3个字母)+命名(具有代表含义的单词) | + +**2. 个人学习总结**:“项目学习情况:”和“心得与反思:”两块空白。 + +### 项目2:书与参考工程的差异,以及模拟器要注意的点 +- **串口列表的填充时机不同**:书中任务1 在 `com_Port_Click` 里填,任务2、3 在窗体 `Load` 里填;参考工程三个任务都在下拉框的 **Click** 事件里填(任务1 的 handler 名是 `com_port_Click`,绑定到 `com_Port.Click`)。**模拟器的 ComboBox 必须支持 Click 事件**,要在点击、展开之前触发,这样 Items 才能在展开前更新。`SerialPort.GetPortNames()` 需要返回虚拟串口列表,例如 COM1–COMn。 +- **IsOpen 写法不同**:书中是 `bool IsOpen=false` 加 `if(!IsOpen){...IsOpen=True}`;参考工程是 `bool IsOpen = true`,先 `IsOpen = !IsOpen` 再判断,事件内容都包在 try/catch 中。书上写成了 `True/False`(首字母大写),C# 编译会报错,模拟器的编译错误提示要能指出这一点。 +- **控件名差异**: + - 任务2、3 参考工程的窗体名都是 Form1(书为 frm_analog / frm_LED)。 + - 组合框名是 groupBox1/2(书为 grp_show / grp_config),标签是 label1–5(书为 lbl_*)。 + - 任务3 的按钮在书上和参考工程里都叫 **`btn_open`**(小写 o),但表2-15 写的是 btn_Open。 + - LED 自定义控件实例名为 **`led`**(类型 `WinFormsDeviceControlLibrary.LED`),表2-1 写的是“LED”。 +- **`DeviceCtrlHelper.ledHelper.Close()`**:书中任务3 第10步调用了它,但参考工程没有调用,《设备控制类库使用说明》中 LED 部分也只列了 `OpenLedCom(string)` 和 `SendLedString(string)`,仿真 dll 很可能没有这个方法。模拟器可以二选一:提供一个空实现以兼容书上的代码,或者给出明确的编译错误。建议提供空实现。 +- **SendLedString 的约定**:成功时返回传入的字符串,失败时返回 `"Error:发送失败"`(见使用说明)。LED 控件 `setLedText(string)` 负责显示,控件内部还有 `LedTimer`,推测会滚动显示。 +- **工具箱里的 4 个自定义控件**:Alarm、Lamp、LED、ShowMsg(图2-5)。ShowMsg 在本范围内没有出现,从 dll 符号看它包含 txt_showMsg、btn_red、btn_green、btn_Clear 和 AddText,后续项目可能用到。 +- 任务2 能力拓展需要 **Timer**(Interval、Enabled、Start/Stop、Tick)和图片显示(Label.Image 或 PictureBox,以及 Visible)。 +- 任务3 知识提炼需要 `Font` 构造函数和中文字体,还需要 `UseSystemPasswordChar`、`\t` 制表符的显示,以及多行 TextBox。 +- 知识提炼中的 SerialPort 直接收发示例(Base64 + Unicode)超出了“学生不学协议”的范围,可以只作阅读材料。如果要能运行,需要模拟 `new SerialPort()`、Open、WriteLine、ReadLine 和 Close。 + +--- + +## 项目3 智慧校园系统——智慧路灯控制模块(书 p53–72 / PDF 56–75,本部分只覆盖到书 p62) + +**项目概述**:ZigBee 是一种近距离、低复杂度、低功耗、低速率、低成本的双向无线通信技术。智慧路灯控制模块借助 ZigBee 远程控制路灯开关。 + +**学习目标** +- 了解智慧路灯控制模块在提升校园管理效率中的作用与重要性。 +- 熟悉 ZigBee 协调器、串口服务器等硬件设备的连接与配置方法。 +- 掌握 C# 在物联网编程中的应用,包括窗体应用程序的创建、控件的使用与事件处理等。 +- 了解并学会使用 DeviceCtrlLibrary 等自定义控件及 API 接口。 +- 具备环境监测与联动控制的能力。 +- 具备良好的团队合作精神与沟通能力,能够与团队成员有效协作,共同完成任务。 +- 面对智慧路灯控制系统开发中遇到的问题,能够独立思考、分析问题,并提出有效的解决方案。 + +### 任务1 使用ZigBee技术控制路灯开关(书 p53–58 / PDF 56–61) + +**任务描述**:用 C# 写程序,通过 ZigBee 无线技术实现路灯的无线开关控制。学生要了解 ZigBee 技术,学会使用 if 语句。(有“扫码看视频”。) + +**任务实施** +1. 把 ZigBee 协调器的串口接到 PC(没有硬件的跳过)。 +2. 新建项目“ZigBee控制路灯”,保存位置为 项目3\任务1。参考工程和后文路径都叫“ZigBee路灯”。 +3. 把自定义控件 WinFormsDeviceControlLibrary.dll 加入工具箱。 +4. 复制 DeviceCtrlLibrary.dll 和 API 下的 dll 到 bin\Debug。 +5. 添加引用 DeviceCtrlLibrary.dll(没有硬件的读者用仿真 dll)。 +6. 按图3-1 搭界面:左边是灯泡(Lamp 控件),下面有“开”“关”两个**单选按钮**;右边 groupBox“配置”里有“路灯”下拉框和“打开”按钮。 +7. 添加 using:`System.IO.Ports`、`DeviceCtrlLibrary`。 +8. 定义方法: + ```csharp + //控制开关灯 + private void LampOnOff(int RelayNum, bool IsOpen) + { + DeviceCtrlHelper.zigBeeHelper.OnOff(RelayNum, IsOpen); + lamp1.OnOff(IsOpen); + } + ``` +9. `Form1_Load`:清空 com_port,再加入 GetPortNames 的结果。 +10. `btn_Open_Click`:如果 `btn_open.Text.Equals("打开")`,执行 `zigBeeHelper.Open(com_port.Text)` 并把按钮文字改为“关闭”;`else if (btn_open.Text.Equals("关闭"))` 时执行 `zigBeeHelper.Close()` 并改回“打开”。 +11. “开”单选按钮的 CheckedChanged:`rb_Open_CheckedChanged` 中调用 `LampOnOff(1, true)`。 +12. “关”单选按钮的 CheckedChanged:调用 `LampOnOff(1, false)`。书中这个方法名**误写为 rb_Open_CheckedChanged**,应为 `rb_Close_CheckedChanged`。 + +**表3-1 “ZigBee路灯”界面控件的属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | ZigBee路灯 | | +| groupBox1 | 组合框 | Text | 配置 | | +| lamp1 | 自定义路灯控件 | | | | +| rb_Open | 单选按钮 | Text | 开 | 开灯 | +| rb_Close | 单选按钮 | Text | 关 | 关灯 | +| com_port | 下拉列表框 | | | 获取PC串口 | +| btn_Open | 按钮 | Text | 打开 | 打开串口 | +| lbl_Lamp | 标签 | Text | 路灯 | | + +**知识提炼**(书 p56–57):**if 语句** +- 两种形式:`if(expression) statement` 和 `if(expression) statement else statement`。 +- 示例:`if(i>1) y=x/i; else { x=i; y=x*x; }`。 +- 悬空 else:没有大括号时,else 与最近的、还没有 else 的 if 关联;加大括号后 else 属于外层 if。 + +**能力拓展** + +*边想边做*(书 p57–58) +1) **闰年判断**:在文本框输入年份,点“判断”按钮。是闰年则在右边标签显示“是闰年”,否则显示“不是闰年”。界面见图3-2 到图3-4:窗体标题“闰年判断”,标签“请输入年份”,一个文本框,右侧标签初始为“是否闰年?”,下方“判断”按钮。示例:1700 显示“不是闰年”,2000 显示“是闰年”。 + - 提示1:字符型数值不能直接赋给数值型变量,要先转换,如 `int a = int.Parse("12")` 或 `int a = Convert.ToInt16("12")`。 + - 提示2:闰年条件是能被 4 整除但不能被 100 整除,或者能被 400 整除。 +2) **路灯按时间自动开关**:要求 18:00 到次日 7:00 开灯,其余时间关灯。用时间控件 Timer(从工具箱选取)完成,每分钟判断一次;程序运行后,打开串口时开启时间控件。 + - 提示1:按表3-2 设置时间控件的关键属性(值留空由学生填)。 + + | 属性 | 值 | + |---|---| + | 名称 | timer1 | + | Interval | ____ | + | Enabled | ____ | + - 提示2:补全代码,再双击时间控件 timer1 进入代码窗口输入。 + ```csharp + private void timer1_Tick(object sender, EventArgs e) + { + int h = DateTime.Now.Hour;//获取当前系统时间小时数 + if( ____ ) //判断时间 + ____ //开关灯 + else + ____ //开关灯 + } + ``` + 参考答案:Interval=60000;`h >= 18 || h < 7`;`LampOnOff(1, true);` / `LampOnOff(1, false);`。**模拟器要用到 `DateTime.Now`,最好允许在模拟器里设置或加速虚拟时钟,否则学生白天测不到开灯。** + +*自主探究*(书 p58):为模块增加以下功能: +1. 实时采集大气压的数值。 +2. 根据大气压控制路灯开关。 + +### 任务2 实现人体红外传感器和路灯联动(书 p58–? / PDF 61–?,本部分读到书 p62) + +**任务描述**:人体红外传感器基于多普勒原理,人靠近感应区域时,开关会无接触、无压力、无火花地迅速发出电气指令。本任务模拟楼梯灯:有人经过时楼梯灯亮,无人时自动熄灭。学生要学会使用单选按钮和复选框,理解数组的作用。(有“扫码看视频”。) + +**任务实施** +1. 把数字量采集器 ADAM4150 和 ZigBee 协调器接到串口服务器,再把串口服务器接到 PC。 +2. 新建项目“人体红外ZigBee联动”(项目3\任务2)。 +3. 添加自定义控件 WinFormsDeviceControlLibrary.dll。 +4. 复制 DeviceCtrlLibrary.dll 和 API 下的 dll 到 bin\Debug。 +5. 添加引用 DeviceCtrlLibrary.dll(没有硬件的读者用仿真 dll)。 +6. 再添加一次自定义控件(书中这一步与第3步重复)。 +7. 按图3-5 搭界面:左边是灯泡(lamp1),下方“开”“关”两个按钮;右边 groupBox“配置”里有“路灯”下拉框、“环境采集”下拉框、“人体开关”复选框和“打开”按钮。 +8. 添加 using:`System.IO.Ports`、`DeviceCtrlLibrary`。 +9. `Form1_Load`:分别清空 com_lamp 和 com_environment,并用 GetPortNames 填充。 +10. `LampOnOff(int RelayNum, bool IsOpen)`:与任务1 相同,即 `zigBeeHelper.OnOff` 加 `lamp1.OnOff`。 +11. `btn_open_Click`:与任务1 相同,按 Text 在“打开”和“关闭”之间切换,用的是 `zigBeeHelper.Open(com_lamp.Text)` 和 `Close()`。 +12. `ch_bodyInfrared_Click`(复选框“人体开关”的 **Click** 事件): + - 如果 `ch_bodyInfrared.Checked`:执行 `DeviceCtrlHelper.adam4150Helper.Open(com_environment.Text)`,`timer1.Start()`,并把 `btn_lampOpen.Enabled` 和 `btn_lampClose.Enabled` 设为 false(禁用手动开关)。 + - 否则:执行 `adam4150Helper.Close()`,`timer1.Stop()`,两个按钮的 Enabled 设回 true。 +13. `btn_lampOpen_Click`:调用 `LampOnOff(1, true)`。 +14. `btn_lampClose_Click`:调用 `LampOnOff(1, false)`。书中第14步标题误写为选中“开”按钮。 +15. `timer1_Tick`(书上写成“双击 timer1 进入 Click 事件”,实际是 Tick): + ```csharp + //时间控件实时判断是否感应到人,如果感应到人则开路灯,否则关闭 + private void timer1_Tick(object sender, EventArgs e) + { + if(DeviceCtrlHelper.adam4150Helper.SetData().bodyInfraredValue) + LampOnOff(1, true); + else + LampOnOff(1, false); + } + ``` + +**表3-3 “人体红外ZigBee联动”界面控件的属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | 人体红外ZigBee联动 | | +| groupBox1 | 组合框 | Text | 配置 | | +| lbl_lamp | 标签 | Text | 路灯 | | +| lbl_environment | 标签 | Text | 环境采集 | | +| com_lamp | 下拉列表框 | | | 显示路灯串口 | +| com_environment | 下拉列表框 | | | 显示环境串口 | +| ch_bodyInfrared | 复选框 | Text | 人体开关 | 控制人体红外传感器是否启动 | +| btn_Open | 按钮 | Text | 打开 | 打开串口 | +| lamp1 | 自定义路灯控件 | | | | +| btn_lampOpen | 按钮 | Text | 开 | 手动控制开灯 | +| btn_lampClose | 按钮 | Text | 关 | 手动控制关灯 | +| timer1 | 时间控件 | Interval | 5000 | | +| timer1 | 时间控件 | Enabled | False | | + +**知识提炼**(书 p61– ,本部分读到 p62) +1. **RadioButton 单选按钮**:可以显示文本、Image 或两者。同一容器(Form、GroupBox、Panel)中的 RadioButton 构成一组,组内互斥;CheckBox 可以多选。Checked 获取或设置状态,Appearance 可以把外观设成切换式按钮或标准选项按钮。表3-4 常见属性:Appearance、AllowDrop、AutoCheck、AutoSize、BackColor、BackgroundImage、BackgroundImageLayout、Bottom、CanFocus、CanSelect、Checked、Enabled、Focused、ForeColor、FontHeight、Image、Text、TextAlign、Visible。 + - **单选按钮示例**“单选按钮”(项目3\任务2),图3-6 窗体“设置字体颜色”:左侧竖排 4 个单选按钮(黑色、红色、蓝色、黄色),右侧 label1 显示“字体颜色[黑色]”。 + + **表3-5 “设置字体颜色”窗体界面各控件相关属性及命名** + + | 控件名称 | 控件类型 | 属性名 | 属性值 | + |---|---|---|---| + | Form1 | 窗体 | Text | 设置字体颜色 | + | radioButton_Black | 单选按钮 | Text | 黑色 | + | radioButton_Red | 单选按钮 | Text | 红色 | + | radioButton_Blue | 单选按钮 | Text | 蓝色 | + | radioButton_Yellow | 单选按钮 | Text | 黄色 | + | label1 | 标签 | Text | 字体颜色[颜色名] | + +**>>> 本部分在此结束(PDF 65 / 书 p62)。下一页(PDF 66 / 书 p63)应接着给出该示例的代码步骤,然后是 CheckBox 复选框知识点、任务2 的能力拓展和自主探究,以及项目3 评价(书 p71 / PDF 74)。<<<** + +### 项目3(到书 p62 为止):书与参考工程的差异,以及模拟器要注意的点 +- **任务1 控件不同**:书中用单选按钮 `rb_Open/rb_Close` 和 CheckedChanged 开关灯;参考工程 `项目3/任务一/ZigBee路灯` 用的是**按钮** `btn_lampOpen/btn_lampClose` 和 Click,与任务2 一样。参考工程没有 rb_*,groupBox 名为 `groupbox1`(小写 b),标签 `label1` 的 Text 为“路 灯”。另外书中表3-1 写 `btn_Open`,代码写 `btn_open`,参考工程也是 `btn_open`。 +- 用单选按钮做开关时要注意:CheckedChanged 在选中和取消选中时**都会**触发。rb_Open 被取消(因为选了 rb_Close)时,如果只写 `LampOnOff(1,true)` 就会出问题。书上的写法只在“两个 handler 各自绑定、不判断 Checked”的情况下“恰好能用”:选“关”时两个事件都会触发,先触发谁决定最终状态。**模拟器必须按真实 WinForms 顺序触发 CheckedChanged**:先是被取消的那个,再是被选中的那个。这样书上的代码会在“开灯”和“关灯”各执行一次后停在正确状态。 +- 参考工程中串口列表在 `com_lamp_Click` / `com_environment_Click`(ComboBox 的 Click)中填充,书中则在 Form1_Load 中填充。参考工程的所有 handler 都包了 try/catch。 +- 参考工程的 timer1 是 Interval=5000,Enabled 为默认 false,与表3-3 一致。 +- `zigBeeHelper.OnOff(RelayNum, IsOpen)`:使用说明写继电器通道为 0~1,书和参考工程都用 1。`zigBeeHelper.Close()` 在使用说明里误拼为 `Colse()`,仿真 dll 里实际是 `Close`。 +- `adam4150Helper.SetData()` 返回 `ADAM4150ResModel`,包含 bool 型的 bodyInfraredValue、fireValue、smokeValue、infraredValue。`adam4150Helper.Open(string PortName, int BodyInfrared_Port=0, int Fire_Port=1, int Smoke_Port=2, int Infrared_Port=3)`;另有 `adam4150Helper.OnOff(int DO, bool IsOpen)`(DO 0~4)。**模拟器要提供“人体红外”的虚拟触发开关,学生用它模拟有人经过。** +- `Lamp` 控件的方法是 `lamp1.OnOff(bool)`,图片资源在 `开发文档/图片资源/控件图片/LampOn.png` 和 `LampOff.png`。 +- 复选框用的是 **Click** 事件而不是 CheckedChanged。Click 触发时 Checked 已经切换过了,模拟器要保证这个时序。 +- 任务1 能力拓展用到 `DateTime.Now.Hour`;自主探究“根据大气压控制路灯”要用 adam4017 的 airPressureValue(string 类型,要 Parse),所以需要同时打开 ADAM4017 和 ZigBee 两个虚拟串口。 diff --git a/docs/教材摘要/2-项目3后半到项目6前半.md b/docs/教材摘要/2-项目3后半到项目6前半.md new file mode 100644 index 0000000..6daca45 --- /dev/null +++ b/docs/教材摘要/2-项目3后半到项目6前半.md @@ -0,0 +1,1122 @@ +# 《物联网编程与应用 第2版》摘要 第二部分(PDF 66–125) + +**页码对照**:PDF 66–114 对应书页 63–111(PDF = 书页 + 3)。书页 112(项目5 后的空白页)似乎没有扫描进来,所以从 PDF 115 起是 PDF = 书页 + 2(PDF 115 = 书 113 项目6 首页,PDF 125 = 书 123)。 + +**范围开头**:从 **项目3 任务2【知识提炼】** 中间开始,具体是知识点"1. RadioButton 单选按钮"的示例代码后半段(PDF 66 / 书 63)。项目3 的项目概述、任务1 全部、任务2 的任务描述和任务实施都在前一段范围里。 +**范围结尾**:停在 **项目6 任务2【任务实施】第15步** `btnDelete_Click` 的 `catch` 块里面(PDF 125 / 书 123)。 + +--- + +## 项目3 智慧校园系统——智慧路灯控制模块(续) + +### 任务2【知识提炼】(续)PDF 66–73 / 书 63–70 + +#### 1. RadioButton 单选按钮(示例后半段,PDF 66–67 / 书 63–64) +- 第3步:选中窗体,在属性窗口点"事件"按钮,找到 `Load` 事件,双击进入代码窗口: + ```csharp + private void Form1_Load(object sender, EventArgs e) + { + this.radioButton1.Checked = true;//设置radioButton1默认选中状态 + } + //设置字体颜色方法 + private void type_method(String value) + { + switch (value) + { + case "Black": this.label1.ForeColor = Color.Black; this.label1.Text = "字体颜色[黑色]"; break; + case "Red": this.label1.ForeColor = Color.Red; this.label1.Text = "字体颜色[红色]"; break; + case "Blue": this.label1.ForeColor = Color.Blue; this.label1.Text = "字体颜色[蓝色]"; break; + case "Yellow": this.label1.ForeColor = Color.Yellow; this.label1.Text = "字体颜色[黄色]"; break; + default: break; + } + } + ``` +- 第4步:选中4个 RadioButton,找到"Click"事件写代码。**书里步骤写的是 Click,代码实际用的是 `radioButtonN_CheckedChanged`**:radioButton1→`type_method("Black")`,2→`"Red"`,3→`"Blue"`,4→`"Yellow"`。 +- 第5步:运行效果见图3-7/3-8,窗体标题"设置字体颜色"。左侧4个单选按钮 黑色/红色/蓝色/黄色,右侧 label1 显示"字体颜色[黑色]"。 + +#### 2. 控件:CheckBox 复选框(PDF 67–69 / 书 64–66) +- 说明:CheckBox 提供一个选项(true/false 或 是/否)。它和 RadioButton 的区别是 CheckBox 可以多选组合,RadioButton 只能在互斥选项里选一个。`Appearance` 决定显示成常规外观还是按钮样式;`ThreeState` 决定是否支持三种状态;`Checked` 获取或设置是否选中。 +- **表3-6 常见属性**:Appearance(外观)、AutoCheck(单击时 Checked/CheckState 和外观是否自动改变)、AutoSize、BackColor、BackgroundImage、CheckAlign(复选标记的对齐方式)、Checked、CheckState、Font、FontHeight、ForeColor、Image、ImageAlign、Location、Parent、ResizeRedraw、Text、TextAlign、ThreeState(是否允许3种状态)、Visible。 +- **表3-7 常见事件**:CheckedChanged(Checked 属性的值改变时发生)、Click(单击控件时发生)。 +- **示例"复选框CheckBox"**:保存在 `D:\物联网编程与应用\项目3\任务2\`,窗体见图3-9"字体状态设置"。 + + **表3-8 "字体状态设置"窗体控件** + + | 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | + |---|---|---|---|---| + | Form1 | 窗体 | Text | 字体状态设置 | | + | checkBox_Bold | 复选框 | Text | 字体加粗 | | + | checkBox_Italic | 复选框 | Text | 字体倾斜 | | + | checkBox_Underline | 复选框 | Text | 字体下画线 | | + | checkBox_Strikeout | 复选框 | Text | 字体删除线 | | + | label1 | 标签 | Text | 普通:Hello_CheckBox | | + + 第3步:给4个 CheckBox 的"Click"事件加代码(**代码里实际是 CheckedChanged,控件名是 checkBox1–4,和表3-8 的 checkBox_Bold 等名字对不上**): + ```csharp + private void checkBox1_CheckedChanged(object sender, EventArgs e) + { type_method(FontStyle.Bold, checkBox1.Checked); } + private void checkBox2_CheckedChanged(...) { type_method(FontStyle.Italic, checkBox2.Checked); } + private void checkBox3_CheckedChanged(...) { type_method(FontStyle.Underline, checkBox3.Checked); } + private void checkBox4_CheckedChanged(...) { type_method(FontStyle.Strikeout, checkBox4.Checked); } + private void type_method(FontStyle fs, bool status)//设置字体的[状态]方法 + { + if (status) + this.label1.Font = new Font(label1.Font, label1.Font.Style | fs); + else + this.label1.Font = new Font(label1.Font, label1.Font.Style & ~fs); + } + ``` + 运行效果见图3-10(加粗)和图3-11(加粗+下画线)。**模拟器需要支持** `new Font(Font, FontStyle)`、`FontStyle` 的位运算 `| & ~`,以及 Strikeout/Underline 的渲染。 + +#### 3. 数组(PDF 69–73 / 书 66–70) +- (1) 定义:相同类型的元素按顺序排列的集合;有数组名、下标、元素;C# 数组下标从0开始;声明时 `[]` 必须写在类型后面(`int[] table;`,不能写 `int table[]`);数组大小不属于类型的一部分(`int[] numbers; numbers = new int[10]; numbers = new int[20];`)。 +- (2) 声明:一维 `int[] numbers;`、多维 `string[,] names;`、交错 `string[][] scores;`。声明之后必须实例化。 +- (3) 创建:`new int[5]`、`new string[5,4]`;交错数组 `byte[][] scores = new byte[5][]; for(...) scores[x] = new byte[4];`;三维 `int[,,] buttons = new int[4,5,3];`;混合写法 `int[][,,][,] numbers;`。 + 示例 `Form1_Click`:建交错数组 `scores[i] = new byte[i + 3]`,然后 `MessageBox.Show("Length of row " + i + " is " + scores[i].Length);`。 +- (4) 初始化:未初始化时元素取类型默认值,字段数组默认是 null。 + - 一维:`new int[5] {1,2,3,4,5}`、`new int[] {...}`、`int[] numbers = {1,2,3,4,5};`、`string[] names = {"Matt","Joanne","Robert"};` + - 多维:`new int[3,2] {{1,2},{3,4},{5,6}}`、`string[,] siblings = {{"Mike","Amy"},{"Mary","Albert"}};` + - 交错:`new int[2][] { new int[] {2,3,4}, new int[] {5,6,7,8,9} }`,可以省略第一维大小,也可以省略 new。交错数组的元素没有初始化语法。 +- (5) 访问元素:`numbers[4] = 5;`、`numbers[1, 1] = 5;`、`numbers[0][0] = 100; numbers[1][1] = 200;` +- (6) 数组是对象:基类是 `System.Array`;`int LengthOfNumbers = numbers.Length;`;还有排序、搜索、复制等方法。 +- (7) 用 foreach 遍历数组:`int[] numbers = {4,5,6,1,2,3,-2,-1,0}; foreach (int i in numbers) MessageBox.Show(i.ToString());`。多维数组同样可以 foreach,`new int[3,2]{{9,99},{8,88},{7,77}}` 输出 `9 99 8 88 7 77`。多维数组用嵌套 for 能控制得更细。 + +### 项目3【能力拓展】PDF 73–74 / 书 70–71 +(这一节在项目3 任务2 后面,是项目3 的最后一个能力拓展。) + +**边想边做**: +> 制作"流水路灯控制"界面,如图3-12所示。 +> 当程序运行时,在文本框中输入1和0组成的一组数(中间用","分隔),单击"开"按钮,则按1、0数值开启和关闭灯,1表示开,0表示关,每隔2s切换,实现流水灯。运行效果如图3-13所示。 + +- **参考界面(图3-12/3-13)**:窗体标题"ZigBee路灯"。左边是一个大灯泡(Lamp 控件,暗/亮两种状态)。右边 GroupBox"配置",里面有标签"路 灯"、串口下拉框(图中 COM3)、按钮"打开"(打开后变成"关闭")。下方有一个 TextBox(示例输入 `1,0,1,1,1,0,0,1`)和两个 RadioButton"开""关"。 +- 提示1:用 `String.Split` 拆分字符串。例: + ```csharp + String sstring = "ab+cd+ef+gh"; + String [] spstring = sstring.Split(new char[] { '+' }); + ``` + 书上写"spstring[0]="ab", spstring[1]="cd", spstring[2]="ed", spstring[3]="gh""(**书里 [2] 印错了,应为 "ef"**)。 +- 提示2:用条件运算符 `?:` 把字符"1""0"转成 True/False。例:`bool pBool= 5>3 ? True:False;`(**书里大写的 True/False 在 C# 里无法编译,应为 `true : false`**)。"5>3"成立就返回问号后第1个值,pBool 为 True,否则返回第2个值。 + +**自主探究**: +> 请为该流水路灯控制程序增加下列功能: +> 添加一个文本框TextBox(Name属性值为intervalTextbox)和一个命令按钮Button(name属性值为configButton,Text属性值为"设置")。文本框用来输入每次开关灯的时间间隔,在文本框输入好时间间隔后,单击"设置"按钮设置时间控件的时间间隔值为当前文本框输入的值,且"开""关"单选按钮可用,启动程序默认不可用,其他功能不变。 + +> 模拟器提示:参考项目里 `项目3/任务一/ZigBee路灯` 用的是 `DeviceCtrlHelper.zigBeeHelper.Open/Close/OnOff(RelayNum,bool)` 加 `lamp1.OnOff(bool)`(WinFormsDeviceControlLibrary.Lamp)。流水灯需要 Timer、Split,以及 RadioButton 的 Enabled。 + +### 项目3【项目评价】PDF 74–75 / 书 71–72 +**1. 项目评价指标**(书里只有指标和要求,没有分值) + +| 任务 | 评价指标 | 具体要求 | +|---|---|---| +| 任务1 | 窗体界面 | 界面布局合理,美观大方,符合使用者的习惯 | +| 任务1 | 功能实现 | 环境值能正确获取 | +| | | 环境值能正确显示 | +| | | 实时时间控制正常 | +| 任务2 | 窗体界面 | 界面布局合理,美观大方,符合使用者的习惯 | +| 任务2 | 功能实现 | 能正常采集红外传感器数据 | +| | | 环境值能正确推送 | +| | | 能实时联动路灯 | +| 职业素养 | 代码书写规范 | 应该遵从结构化的要求,采用缩进的格式 | +| | 注释书写规范 | 做到清晰、准确地描述内容。对于程序中复杂的部分必须有注释加以说明。注释量要适中,避免过多或过少导致阅读困难 | +| | 变量命名规范 | 选择有意义的英文进行命名,使变量名能清楚地表达其含义。尽量少使用人名、地名和汉语拼音。应使用那些约定俗成的缩写。多个单词组成的变量名,每个单词的首字母应该大写 | +| | 界面常用控件命名约定 | 窗体控件命名以控件名称缩写前3个字母开头,以具有代表含义的词来命名,其形式为:控件缩写(前3个字母)+命名(具有代表含义的单词) | + +**2. 个人学习总结**:空白表,两栏"项目学习情况""心得与反思"(每个项目都有,后面不再重复)。 + +--- + +## 项目4 智慧校园系统——入侵信息监测模块(PDF 76–93 / 书 73–90) + +**项目概述**:实时安防系统的入侵信息监测,是借助物联网技术把感应器、网络摄像机和警报装置嵌入各种安防监控设备,由计算机采集监测数据,并实现拍摄和报警。本项目采集红外对射入侵传感器的数据,经数字量采集器 ADAM4150 传到计算机,显示在程序窗体中;再用程序分别打开报警灯、控制网络摄像机拍照。 + +**学习目标**: +- 能理解C#重写函数的作用。 +- 能正确实现数据的读取、摄像头图像的显示。 +- 能理解静态、switch、foreach语句的作用,并能灵活使用。 +- 形成良好的代码编写规范,具备良好的问题解决能力。 +- 培养严谨、规范的编程思维和习惯,增强逻辑思维和抽象思维能力。 +- 增强职业认识、提升职业素养、树立职业信心。 + +### 任务1 采集红外对射入侵传感器数据(PDF 76–81 / 书 73–78) +**任务描述**:红外对射入侵传感器由红外发射机和接收机组成,两者之间的光束被遮断时就进入报警状态。本任务模拟校园围墙安防系统,用 C# 把传感器采集的数据实时显示在窗体上。学生要学会 foreach,理解 foreach 和 for 的区别。 + +**任务实施步骤(压缩版)**: +1. ADAM4150 串口接 PC 串口,红外对射传感器接采集器 **DI3** 口。 +2. 新建 WinForms 项目"红外对射采集",保存在 `D:\物联网编程与应用\项目4\任务1`。 +3. 从配套资源中找到 ADAM4150 用的 `DeviceCtrlLibrary.dll`,把"API"文件夹下所有 dll 复制到 `...\红外对射采集\红外对射采集\bin\Debug`。 +4. 添加引用 `DeviceCtrlLibrary.dll`。(**注:没有硬件设备的读者,请添加仿真动态库文件,无须添加"API"文件夹下的dll文件**,后面各任务都有这句注释。) +5. 按图4-1创建窗体(表4-1)。 +6. `using System.IO.Ports; using DeviceCtrlLibrary;` +7. 全局变量 `bool IsOpen = false;`(书上印成 `boolIsOpen`,少了空格)。 +8. `Form1_Load`:`com_port.Items.Clear(); foreach (string item in SerialPort.GetPortNames()) com_port.Items.Add(item);` +9. `btn_open_Click`:`IsOpen = !IsOpen;`。打开时调用 `DeviceCtrlHelper.adam4150Helper.Open(com_port.Text); timer1.Start(); btn_open.Text="关闭";`;关闭时调用 `adam4150Helper.Close(); timer1.Stop(); btn_open.Text="开启";`。 +10. 选中 timer1,在属性窗口的事件栏(闪电图标)找到 Tick 事件(图4-2)。代码为 `txt_time.Text = DateTime.Now.ToString(); if (DeviceCtrlHelper.adam4150Helper.SetData().infraredValue) txt_even.Text = "发现入侵"; else txt_even.Text = "正常";` +11. 运行,选串口,点"开启",效果见图4-3(com1,2018-12-08 14:22:45,发现入侵)。 + +**表4-1 窗体界面控件属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | 红外对射 | | +| com_port | 下拉列表框 | | | 显示串口 | +| btn_open | 按钮 | Text | 开启 | | +| txt_time | 文本框 | | | 显示时间 | +| txt_even | 文本框 | | | 显示入侵信息 | +| timer1 | 时间控件 | Interval | 2000 | | +| lbl_port | 标签 | Text | 环境串口 | | +| lbl_time | 标签 | Text | 时间 | | +| lbl_even | 标签 | Text | 入侵 | | + +图里还有一个 GroupBox"数据显示",包着"时间""入侵"两行,表里没列出来。 + +**小贴士**:`DeviceCtrlHelper.adam4150Helper.SetData().infraredValue` 获取红外对射入侵传感器的数据,返回 bool,true 表示入侵,false 表示正常。 + +**知识提炼**: +- foreach 语句:列出集合中所有元素。用 in 分隔,左边是变量,右边是集合,每次循环从集合取下一个元素放进变量,取完为止。示例:`string StrMsg=""; int[] array = new int[]{1,2,3,4,5,6,7,8,9}; foreach (int i in array) { StrMsg = StrMsg + i.ToString(); } MessageBox.Show(StrMsg);` + +**能力拓展·边想边做**(PDF 80–81): +> 通过"任务实施"的操作步骤,已经实现了获取红外对射入侵传感器数据的功能。本环节将实现该模块在有入侵警告时推送报警信息至LED显示屏,并具备警报解除功能。根据下列提示,实现上述功能。注:本题涉及串口服务器,需正确配置串口服务器与路由器来实现该功能。 + +- 提示1,**图4-4 检测流程图**:开始 → 判断"是否入侵"。否 → 显示正常信息 → 回到"是否入侵"。是 → 显示入侵信息 → 判断"是否推送"。是 → 生成报警信息并推送 → 修改推送状态 → 结束;否 → 结束。 +- **图4-5 解除报警流程图**:开始 → 推送空白信息至LED → 修改推送状态 → 显示正常信息 → 结束。 +- 提示2:根据流程图补全代码,写在 timer1 的 Tick 事件里并调试: + ```csharp + private void timer1_Tick(object sender, EventArgs e) + { + txt_time.Text = DateTime.Now.ToString(); + if(DeviceCtrlHelper.adam4150Helper.SetData().infraredValue) + { + txt_even.Text = "发现入侵"; + if( ____ ) + { + ____ + ____ ) // 书上第二条空线以")"结尾,照原样保留 + } + } + else + { + txt_even.Text = "正常"; + } + } + ``` + +**自主探究**: +> 修改该模块使其成为夜间门禁防盗报警系统,要具有如下功能,效果如图4-6所示。 +> 1)有入侵则在界面上闪烁显示。 +> 2)将入侵信息推送至LED显示屏上显示。 + +**参考界面(图4-6)**:标题"红外对射"。上方是"环境串口"下拉框(COM2)和"LED串口"下拉框(COM3),右侧有"关闭"和"解除报警"两个按钮。GroupBox"数据显示"里有时间(2017/1/17 15:37:00)、入侵(发现入侵),下面一个闪烁的标签"检测到入侵,请注意!"。 + +### 任务2 控制网络摄像机(PDF 81–87 / 书 78–84) +**任务描述**:网络摄像机是传统摄像机与网络视频技术结合的产品,可以通过网络访问和控制,实时监控现场。本任务模拟校园视频监控系统,用 C# 把摄像机的实时图像显示在窗体上,并实现上下左右转动和拍照。学生要学会 PictureBox 的用法和 static 的特点。 + +**任务实施步骤**: +1. 网络摄像机和 PC 都连到无线路由器,并配置好摄像机。 +2. 新建项目"网络摄像机",保存在 `D:\物联网编程与应用\项目4\任务2`。 +3. 把摄像机用的 DeviceCtrlLibrary.dll 和 API 下所有 dll 复制到 `...\网络摄像机\网络摄像机\bin\Debug`。 +4. 添加引用(仿真库注释同上)。 +5. 按图4-7创建窗体(表4-2):左边是大块黑色图片框;右边是"摄像机IP"文本框,下面是十字形方向键,中间是"开",再往下是"拍照"。 +6. `using System.IO.Ports; using DeviceCtrlLibrary;`(**书里漏了 `using System.IO;`,可是 `Directory` 要用它;参考代码写的是 `using System.IO;`,没有 Ports**)。 +7. 4个全局变量: + ```csharp + static string CameraPath = AppDomain.CurrentDomain.BaseDirectory + "Camera/";//拍照保存路径 + static string IpCameraIPAddress = "192.168.14.131";//网络摄像机IP + bool IsOpen = false; + Image ReadImage = null;//存储摄像机图片 + ``` +8. `void ipCameraHelper_ImageRead(ImageReadEvenArgs e) { ReadImage = e.img; pictureBox1.Image = ReadImage; }`。类型名写的就是 `ImageReadEvenArgs`,原文如此,和库里一致。 +9. `Form1_Load`:`if(!Directory.Exists(CameraPath)) Directory.CreateDirectory(CameraPath); DeviceCtrlHelper.ipCameraHelper.ImageRead += ipCameraHelper_ImageRead;//绑定图片显示事件` +10. `btn_open_Click`:`IsOpen=!IsOpen;`。打开时 `IpCameraIPAddress = txt_ip.Text; DeviceCtrlHelper.ipCameraHelper.Open(IpCameraIPAddress, "admin", "");//用户名admin,密码为空` 然后 `DeviceCtrlHelper.ipCameraHelper.Start(); btn_open.Text = "关";`;关闭时 `ipCameraHelper.Stop(); btn_open.Text = "开";`。 +11–14. 方向键分别调用 `DeviceCtrlHelper.ipCameraHelper.PanUp()`、`PanDown()`、`PanLeft()`、`PanRight()`。 +15. `btn_camera_Click`:`if (ReadImage != null) ReadImage.Save(CameraPath + DateTime.Now.ToString("yyyyMMdd_HH mm ss") + ".jpg");`(格式串里本来就带空格)。 +16. 运行,填摄像机IP(图中是 192.168.2.50),点"开",效果见图4-8。 + +**表4-2 "网络摄像机"窗体界面控件属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | 网络摄像机 | | +| pictureBox1 | 图片框 | BackColor / SizeMode | Black / StretchImage | 显示图像 | +| label1 | 标签 | Text | 摄像机IP | | +| txt_ip | 文本框 | | | 输入摄像机IP | +| btn_open | 按钮 | Text | 开 | 打开/关闭摄像机 | +| btn_up | 按钮 | Text | ↑ | 向上转 | +| btn_down | 按钮 | Text | ↓ | 向下转 | +| btn_left | 按钮 | Text | ← | 向左转 | +| btn_right | 按钮 | Text | → | 向右转 | +| btn_camera | 按钮 | Text | 拍照 | 截取图像 | + +**知识提炼**: +1. PictureBox 图片框:能显示 .bmp、.ico、.wmf、.jpg、.gif。**表4-3 常见属性**: + - BorderStyle:边框样式,枚举(书里写成"Emun")。None 无边框,FixedSingle 单线边框,fixed3D 立体边框。 + - Image:要显示的图片,也可以用 `Image.FromFile` 加载。 + - SizeMode:显示模式,枚举。Normal 置于左上角,图片大于控件时被剪切;AutoSize 自动调整图片框大小,和图片一样大;CenterImage 图片框比图片大时居中,比图片小时剪切边沿;StretchImage 拉伸或收缩以适合图片框;zoom 按原比例缩放。 +2. static:用 static 声明的成员属于类本身,不属于对象,不用实例化,直接通过类名调用。示例:`public class StaticClass { public static int x = 10; }`,然后在 `Form1_Load` 里 `MessageBox.Show(StaticClass.x.ToString());`。 + +**能力拓展·边想边做**: +> 通过"任务实施"的操作步骤,已经实现了摄像机显示、控制与抓拍的功能。本环节将为该模块添加每天定时抓拍的功能,固定时间间隔为:2h、1h、45min、30min、15min、5min。请根据下列提示,实现上述功能。 +> 提示:根据之前所学利用if条件语句完善该代码,然后在程序中双击"开"按钮进入代码编写窗口,输入完整代码并调试。 + +```csharp +private void btn_open_Click(object sender, EventArgs e) +{ + IsOpen = !IsOpen;//取反IsOpen变量,变更按下按钮后串口的打开或关闭状态 + if (IsOpen) + { + IpCameraIPAddress = txt_ip.Text; + DeviceCtrlHelper.ipCameraHelper.Open(IpCameraIPAddress, "admin", "");//打开网络摄像机 + DeviceCtrlHelper.ipCameraHelper.Start();//开启网络摄像机监控 + btn_open.Text = "关";//将按钮文字改为"关" + if (comboBoxDateSelect.Text.Trim()=="2h") + { + timer1.Interval = 7200000; + } + else if (____) + { + timer1.Interval = 3600000; + } + else if (comboBoxDateSelect.Text.Trim() == "45min") // 书中这两行带下划线,是示范答案 + { + timer1.Interval = 2700000; + } + else if ( ____ ) + { + ____ + } + else if ( ____ ) + { + ____ + } + timer1.Start(); + } + else + { + DeviceCtrlHelper.ipCameraHelper.Stop();//关闭网络摄像机 + btn_open.Text = "开";//将按钮文字改为"开" + timer1.Stop(); + } +} +``` +注意:一共列了6档间隔,骨架只有5个分支(5min 没有位置)。另外要新增 ComboBox `comboBoxDateSelect` 和 timer1,Tick 里应该做抓拍,书里都没给控件表,也没给 Tick 代码。 + +**自主探究**: +> 修改该模块使其成为智慧校园监控系统,在东围墙安装一台红外对射入侵传感器与一台网络摄像机,要具有如下功能,运行效果如图4-9所示。 +> 1)实时监控围墙情况。 +> 2)如发现入侵则自动抓拍。 + +**参考界面(图4-9)**:标题"网络摄像机",左边大图像区。右边三个 GroupBox: +- "设置":摄像机IP(192.168.2.90)、串口配置下拉框、"关闭"按钮。 +- "入侵监控区":标签"发现入侵"。 +- "控制区":↑ ← → ↓ 四个按钮。 + +### 任务3 控制报警灯(PDF 87–92 / 书 84–89) +**任务描述**:报警灯用在危险场所,靠闪光发出警示。发生事故、火灾等紧急情况时,控制器发出信号启动报警电路,灯就闪光报警。本任务模拟校园安防报警系统,用 C# 打开和关闭报警灯。学生要掌握 switch 语句。 + +**任务实施步骤**: +1. 采集器串口接 PC 串口,报警灯通过继电器接到采集器 **DO0** 口。 +2. 新建"报警灯"项目,保存在 `D:\物联网编程与应用\项目4\任务3`。 +3. **把自定义控件 `WinFormsDeviceControlLibrary.dll` 添加到工具箱。** +4. 把 ADAM4150 的 DeviceCtrlLibrary.dll 和 API 下的 dll 复制到 `...\报警灯\报警灯\bin\Debug`。 +5. 添加引用(仿真库注释同上)。 +6. 按图4-10建窗体(表4-4):左边是报警灯图,下面"开""关"两个按钮;右边 GroupBox"配置",里面是"报警灯"下拉框和"开启"按钮。 +7. `using System.IO.Ports; using DeviceCtrlLibrary;` +8. `bool IsOpen = false;`(书上又印成 `boolIsOpen`) +9. `Form1_Load` 填充串口,和任务1 一样。 +10. 定义方法: + ```csharp + private void AlarmOnOff(string Device, bool IsOpen) + { + //利用switch语句打开或关闭继电器模块 + switch(Device) + { + case "Alarm": DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen); + alarm1.OnOff(IsOpen); break; + //开启或关闭继电器0模块,报警灯接入DO0,更改界面报警灯状态 + case "LED1": DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen); break;//开启或关闭继电器1模块 + case "LED2": DeviceCtrlHelper.adam4150Helper.OnOff(2, IsOpen); break;//开启或关闭继电器2模块 + default: MessageBox.Show("没有合适的接口程序!", "错误提示!"); break;//若为其他模块,则提示错误 + } + } + ``` +11. `btn_open_Click`:只开关串口并改按钮文字("关闭"/"开启"),**没有 timer**。 +12. `btn_alarmOpen_Click` 里调用 `AlarmOnOff("Alarm", true)`;13. `btn_alarmClose_Click` 里调用 `AlarmOnOff("Alarm", false)`。 +14. 运行,选串口点"开启",再点"开""关"控制报警灯,见图4-11(灯变红亮)。 + +**表4-4 "报警灯"界面控件属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | 报警灯 | | +| Alarm | 自定义控件 | | | 显示报警灯状态 | +| btn_alarmOpen | 按钮 | Text | 开 | | +| btn_alarmClose | 按钮 | Text | 关 | | +| groupBox1 | 组合框 | Text | 配置 | | +| label1 | 标签 | Text | 报警灯 | | +| com_port | 下拉列表框 | | | 显示串口 | +| btn_open | 按钮 | Text | 开启 | | + +表里的控件名写的是"Alarm"(其实是控件类型名),代码和参考项目里的实例名都是 `alarm1`,类型是 `WinFormsDeviceControlLibrary.Alarm`。 + +**知识提炼**:switch 语句。 +- 作用:类似 if,用于从一组互斥分支里选一个执行。格式为 `switch(表达式){ case "常量一":语句;break; … case "常量N":语句;break; default:语句;break; }`。 +- 规则:每个 case 指定一个常数值,匹配上就跳进去执行,直到 break 跳出;都不匹配就执行 default;没有 default 就什么都不做,直接执行 switch 后面的语句。 +- 示例 `Form1_Load`:`string grade="A"; switch(grade){ case "A":MessageBox.Show("您的成绩为:优秀");break; case "B":…"良好"; case "C":…"一般"; case "D":…"合格"; case "E":MessageBox.Show("您的成绩为:不合格,要好好努力了!");break; default: MessageBox.Show("无效的成绩等级!");break; }` + +**能力拓展·边想边做**: +> 通过"任务实施"的操作步骤,已经实现了报警灯的打开与关闭功能。本环节为该模块添加有入侵警告时打开报警灯的功能。请根据下列提示,实现上述功能。图4-12所示为实现本功能的流程图,供参考。 + +**图4-12 监测流程图**: +- 开始 → 判断"是否入侵"。 +- 是 → 判断"报警灯是否已打开"。否 → 打开报警灯 → 回到"是否入侵";是 → 结束。 +- 否 → 判断"报警灯是否打开"。是 → 关闭报警灯 → 回到"是否入侵";否 → 结束。 + +提示:根据流程图补全代码,写在 timer1 的 Tick 事件里: +```csharp +private void timer1_Tick(object sender, EventArgs e) +{ + if (DeviceCtrlHelper.adam4150Helper.SetData().infraredValue) + { + txt_even.Text = "发现入侵"; + if( ____ ) + { + ____ + AlarmState = true; + } + } + else + { + txt_even.Text = "正常"; + if ( ____ ) + { + ____ + AlarmState = false; + } + } +} +``` +这里要额外加 txt_even、timer1 和 `bool AlarmState`,任务3 的控件表里都没有。 + +**自主探究**: +> 修改该模块使其成为智慧校园围墙监控报警系统,要具有如下功能,运行效果如图4-13所示。 +> 1)实时检测入侵信息。 +> 2)若有入侵则推送报警信息至LED显示屏。 +> 3)若有入侵则打开报警灯。 + +**参考界面(图4-13)**:标题"报警灯",左边是报警灯。右边 GroupBox"配置":串口配置(COM2)、LED串口(COM3)、"关闭"按钮。下面 GroupBox"数据显示":入侵文本框(发现入侵)。 + +### 项目4【项目评价】PDF 92–93 / 书 89–90 + +| 任务 | 评价指标 | 具体要求 | +|---|---|---| +| 任务1 | 窗体界面 | 窗体控件正确,布局美观 | +| | 开关功能 | 按钮能打开、关闭串口;开启、停止定时器;更改按钮显示文字 | +| | 实时显示 | 能实时显示入侵数据 | +| 任务2 | 窗体界面 | 窗体控件正确,布局美观 | +| | 开关功能 | 能正确打开、关闭摄像机 | +| | 图像实时显示 | 能正确显示图像 | +| | 旋转控制功能 | 能控制摄像机上下左右旋转 | +| | 抓拍功能 | 能抓拍摄像机当前图像 | +| 任务3 | 窗体界面 | 窗体控件正确,布局美观 | +| | 开关功能 | 能正确打开、关闭报警灯 | +| 职业素养 | 代码书写规范 | 应该遵从结构化的要求,采用缩进的格式 | +| | 注释书写规范 | 做到清晰、准确地描述内容。对于程序中复杂的部分必须有注释加以说明。注释量要适中,避免过多或过少导致阅读困难 | +| | 变量命名规范 | 选择有意义的英文进行命名,使变量名能清楚地表达其含义。尽量少使用人名、地名和汉语拼音。应使用那些约定俗成的缩写。多个单词组成的变量名,每个单词的首字母应该大写 | + +(没有分值。和项目3 比,少了"界面常用控件命名约定"这一行。)后面跟个人学习总结空表。 + +--- + +## 项目5 智慧校园系统——灾情信息监测模块(PDF 94–114 / 书 91–111) + +**项目概述**:实时安防系统的灾情信息监测,是借助物联网技术把火焰、烟雾传感器嵌入各种安防监控设备,由计算机采集灾情数据,并实现推送和报警。本项目采集火焰传感器和烟雾传感器的数据,经数字量采集器 ADAM4150 传到计算机,显示在程序窗体中;再用程序把灾情信息推送到 LED 显示屏。 + +**学习目标**: +- 能正确理解for、while循环语句,并能使用循环语句解决简单问题。 +- 能理解Datetime类和continue语句。 +- 了解多线程的概念,学会简单的多线程编写。 +- 形成良好的代码编写规范,培养代码优化和性能调优的能力。 +- 培养严谨、规范的编程思维和习惯,增强逻辑思维和抽象思维能力。 +- 增强职业认识,提升职业素养,树立职业信心。 + +### 任务1 采集火焰传感器数据(PDF 94–101 / 书 91–98) +**任务描述**:火焰传感器利用红外线对火焰非常敏感的特点,用特制的红外接收管检测火焰。本任务模拟校园安防的灾情监测系统,用 C# 把火焰传感器的数据实时显示在窗体上。学生要掌握 for 和 return 语句,了解 DateTime 类常见的属性和方法。 + +**任务实施步骤**: +1. ADAM4150 接 PC,火焰传感器接 **DI1**。 +2. 新建"火焰传感器数据采集"项目,保存在 `D:\物联网编程与应用\项目5\任务1`。 +3. 复制 dll 到 `...\火焰传感器数据采集\火焰传感器数据采集\bin\Debug`。 +4. 添加引用(仿真库注释同上)。 +5. 按图5-1建窗体(表5-1)。 +6. `using System.IO.Ports; using DeviceCtrlLibrary;` +7. `bool IsOpen=false;` +8. `Form1_Load` **这次用 for 循环**:`for (int i=0; i 通过"任务实施"的操作步骤,已经实现了实时获取火焰传感器数据的功能。本环节将使用多线程机制来实现该功能,为任务2做好准备。请根据下列提示,实现上述功能。 + +- 提示1,**图5-3 线程运行流程图**:开始 → 判断"是否检测到火焰",是 → 显示火情,否 → 显示正常 → 线程休眠2s → 回到判断(图上有"结束"框,但循环不会自己退出)。 +- **图5-4 开关监测流程图**:开始 → 判断"串口是否打开"。 + - 否 → 打开串口 → 判断"是否第一次运行线程",是 → 开启线程,否 → 继续已挂起的线程 → 结束。 + - 是 → 挂起线程 → 结束。 +- 提示2:进入代码窗口,在 btn_open_Click 事件里实现功能,线程方法见表5-4。**表5-4 线程方法**: + + | 名称 | 说明 | + |---|---| + | Start() | 开启线程 | + | Resume() | 继续已挂起的线程 | + | Suspend() | 挂起线程 | + | Sleep(int32) | 线程休眠,Thread.Sleep(2000)为休眠2s | + | Abort() | 终止线程 | +- 提示3:找到 GetData() 方法,按流程图补全: + ```csharp + private void GetData() + { + while ( ____ ) + { + if ( ____ ) + { + txt_time.Text=DateTime.Now.ToString(); + txt_even.Text="发现火情"; + } + else + { + txt_time.Text=DateTime.Now.ToString(); + txt_even.Text="正常"; + } + } + } + ``` + 骨架里没有 `Thread.Sleep(2000)`,流程图里却有。 + +**自主探究**: +> 分别在教室A、B安装火焰传感器模拟智慧教室火焰检测系统,要具有如下功能,运行效果如图5-5所示。注:本题使用DI1与DI2接口,接入火焰传感器。 +> 1)有火焰灾情时在界面上闪烁显示,并显示教室位置。 +> 2)有火焰灾情时打开报警灯。 + +**参考界面(图5-5)**:标题"教室火焰检测"。顶部是"串口"下拉框(COM1)和"关闭"按钮。下面两个 GroupBox"教室A""教室B",每个里有标签"火焰状态"和状态文字("正常!" / "发现火情!")。 +(注意 DI2 在任务实施里接的是烟雾传感器,这里两路都当火焰用,模拟器要能把 DI1、DI2 都设成火焰。) + +### 任务2 采集烟雾传感器数据(PDF 101–106 / 书 98–103) +**任务描述**:烟雾传感器通过监测烟雾浓度来做火灾报警提示。本任务模拟校园安防的灾情监测系统,用线程把烟雾传感器的数据实时显示在窗体上。学生要掌握 while 语句,理解 while 和 for 各自的特点,初步了解线程 Thread 的概念和用法。 + +**任务实施步骤**: +1. 烟雾传感器接 **DI2**。 +2. 新建"烟雾传感器数据采集"项目,保存在 `D:\物联网编程与应用\项目5\任务2`。 +3. 复制 dll 到 `...\烟雾传感器数据采集\烟雾传感器数据采集\bin\Debug`。 +4. 添加引用(仿真注释同上)。 +5. 按图5-6建窗体(表5-5,**没有 timer**)。 +6. `using System.IO.Ports; using DeviceCtrlLibrary; using System.Threading;//引用线程命名空间` +7. 全局变量:`bool IsOpen=false; bool IsFirst=true;//记录线程是否第一次打开 Thread thd;//定义Thread线程` +8. GetData: + ```csharp + private void GetData() + { + //线程运行方法,不断循环运行 + while (true) + { + if (DeviceCtrlHelper.adam4150Helper.SetData().smokeValue)//判断烟雾状态,True为发现烟雾灾情,False为正常状态 + { + txt_time.Text = DateTime.Now.ToString(); + txt_even.Text = "发现烟情"; + } + else + { + txt_time.Text = DateTime.Now.ToString(); + txt_even.Text = "正常"; + } + Thread.Sleep(2000);//线程休眠2s + } + } + ``` +9. `Form1_Load`:用 foreach 填串口,然后 `thd = new Thread(GetData);//实例化线程,绑定线程运行方法` 和 `Control.CheckForIllegalCrossThreadCalls = false;//允许跨线程调用`。 +10. `btn_open_Click`: + - `IsOpen=!IsOpen;` + - 打开时:`adam4150Helper.Open(com_port.Text); btn_open.Text="关闭"; if (IsFirst) { IsFirst=false; thd.Start(); } else { thd.Resume(); }` + - 关闭时:`adam4150Helper.Close(); btn_open.Text="开启"; thd.Suspend();` +11. 窗体 FormClosing 事件:`private void Form1_FormClosing(object sender, FormClosingEventArgs e) { thd.Abort();//终止线程 }` +12. 运行效果见图5-7(COM1,2016-12-15 17 10 29,正常)。 + +**表5-5 烟雾采集窗体界面控件属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | 烟雾 | | +| com_port | 下拉列表框 | | | 输入/显示串口 | +| btn_open | 按钮 | Text | 开启 | | +| txt_time | 文本框 | | | 显示时间 | +| txt_even | 文本框 | | | 显示入侵信息 | +| lbl_port | 标签 | Text | 串口 | | +| lbl_time | 标签 | Text | 时间 | | +| lbl_even | 标签 | Text | 事件 | | + +**模拟器需要支持**: +- `System.Threading.Thread` 的 Start、Suspend、Resume、Abort、Sleep,以及 `Control.CheckForIllegalCrossThreadCalls = false`(让后台线程直接改 UI 也不报错)。 +- Suspend、Resume、Abort 在新版 .NET 里已过时,.NET Core/5+ 调 Abort 会抛 PlatformNotSupportedException,模拟器得自己模拟这几个行为。 +- 参考项目的 FormClosing 和书不一样:`if(thd.ThreadState==ThreadState.Unstarted) thd.Abort();`。这种写法对已挂起的线程不 Abort;对没启动过的线程调 Abort,在原 .NET Framework 上也有问题。 + +**知识提炼**: +1. while 循环:条件为真就反复执行,直到条件为假,格式 `while(条件){语句;}`。示例求1到10的和:`int i = 1, sum = 0; while(i<11){ sum = sum + i; i = i + 1; } MessageBox.Show(sum.ToString());` +2. Thread 线程:.NET 是多线程环境,C# 程序从一个由系统自动创建的主线程开始,可以再创建额外线程。示例是一个控制台程序: + ```csharp + class ThreadTest + { + static void Main(string[] args) + { + Thread thd = new Thread(PrintB); + thd.Start(); + while (true) Console.Write("A"); + } + static void PrintB () + { + while (true) Console.Write("B"); + } + } + ``` + +**能力拓展·边想边做**: +> 通过"任务实施"的操作步骤,已经实现了获取烟雾传感器数据的功能。本环节将实现当有烟雾报警时,使用多线程在程序界面闪烁红色文字。请根据下列提示,实现上述功能。 + +提示1:根据以下提示,完善GetData()方法,进入代码编写窗口,输入代码。 +```csharp +private void GetData() +{ + while (true) + { + if (DeviceCtrlHelper.adam4150Helper.SetData().smokeValue) + { + txt_time.Text=DateTime.Now.ToString(); + txt_even.Text="发现烟情"; + btn_remove.Visible=true; + btn_open.Visible=true; + ____ //挂起thd线程 + ____ //开启hdAlarm线程 + } + else + { + txt_time.Text=DateTime.Now.ToString(); + txt_even.Text="正常"; + } + ____ //调用Thread.Sleep(int)方法使线程休眠一段时间 + } +} +``` +提示2:根据以下提示,完善该代码。选择"Alarm"方法,进入代码编写窗口,输入代码。 +```csharp +private void Alarm() +{ + while( ____ )//设置while条件,使线程方法体循环运行 + { + if (lbl_alarm.Visible == true) + { + lbl_alarm.Visible = false; + } + else + { + lbl_alarm.Visible =true; + } + ____ //调用Thread.Sleep(int)方法,线程休眠一段时间,调试后设置合适的时间,使线程达到理想效果 + } +} +``` +提示3:根据以下提示,完善"btn_remove"按钮控件的"Click"事件,进入代码编写窗口,输入代码并调试。 +```csharp +private void btn_remove_Click(object sender, EventArgs e) +{ + txt_even.ForeColor = Color.Black; + ____ //重新开启thd实时采集线程 + btn_remove.Visible = false; + lbl_alarm.Visible = false; + ____ //终止thdAlarm线程 +} +``` +还需要补的东西:btn_remove(解除报警按钮,默认隐藏)、lbl_alarm(红色闪烁标签)、`Thread thdAlarm = new Thread(Alarm)`。注释里的"hdAlarm"是原文笔误,指的就是 thdAlarm。 + +**自主探究**: +> 分别在教室A、B安装烟雾传感器模拟智慧教室烟雾检测系统,要具有如下功能,运行效果如图5-8所示。 +> 1)有烟雾灾情时开启报警灯。 +> 2)推送灾情位置和发现时间至LED显示屏。 + +**参考界面(图5-8)**:标题"烟雾"。上方"串口"(COM2)、"LED串口"(COM3)两个下拉框和"关闭"按钮。下面 GroupBox"教室A"(烟雾状态 / 发现烟情)和"教室B"(烟雾状态 / 正常)。 + +### 任务3 实时推送灾情信息(PDF 107–111 / 书 104–108) +**任务描述**:本任务模拟校园安防的灾情监测系统,结合任务1 和任务2,用 C# 把烟雾、火焰传感器的灾情信息推送到 LED 屏,用数组存储灾情信息并在窗体上显示。学生要进一步掌握数组,理解 continue 的作用,掌握 continue 和 break 的区别。 + +**任务实施步骤**: +1. 采集器串口和 LED 屏串口都接到**串口服务器**上;火焰、烟雾传感器分别接 DI1、DI2;正确配置串口服务器和路由器。 +2. 新建"实时推送灾情信息"项目,保存在 `D:\物联网编程与应用\项目5\任务3`。 +3. 复制 ADAM4150 的 dll 和 API 到 `...\实时推送灾情信息\实时推送灾情信息\bin\Debug`。 +4. 添加引用(仿真注释同上)。 +5. 新建 **"RecordClass" 类文件**: + ```csharp + public static string[,] record = new string[50, 2] ;//定义一个二维数组,用于存储灾情信息的时间和内容 + public static int recoudeindex = 0;//用于记录灾情信息的索引位置 + ``` + 参考项目里 RecordClass 是没有 namespace 的全局 `public class RecordClass`。 +6. 按图5-9建窗体(表5-6):顶部是一条很宽的黑色 LED 显示控件 led1。下方 GroupBox"数据采集"里有时间、火焰、烟雾三个文本框和一个竖长的"获取"按钮。右边 GroupBox"配置"里有"L E D"下拉框、"环境采集"下拉框、"开启"按钮和"历史灾情信息"按钮。 +7. `using System.IO.Ports; using DeviceCtrlLibrary; using DeviceCtrlLibrary.Model;//引用设备控制库下的Model命名空间` +8. 全局变量:`ADAM4150ResModel ResModel; bool IsOpen = false; string LED_MSG = "";//存储LED显示屏所需发送的文字信息` +9. `Form1_Load`: + ```csharp + com_environmentPort.Items.Clear(); com_ledPort.Items.Clear(); + //将系统串口名依次加入下拉列表框中,由于本题使用串口服务器,所以COM1计算机串口可不加入串口列表 + for (int i = 0; i < SerialPort.GetPortNames().Length;i++ ) + { + //判断计算机的默认串口,将其排除 + if (SerialPort.GetPortNames()[i].ToUpper()=="COM1") + { + continue;//跳出此次循环 + } + com_environmentPort.Items.Add(SerialPort.GetPortNames()[i].ToString()); + com_ledPort.Items.Add(SerialPort.GetPortNames()[i].ToString()); + } + ``` + 参考项目没有排除 COM1,用两个 foreach 分别填充。书上的运行截图里 LED 用的却正是 COM1,自相矛盾。 +10. `btn_Open_Click`: + - 打开时:`DeviceCtrlHelper.adam4150Helper.Open(com_environmentPort.Text);`、`DeviceCtrlHelper.ledHelper.OpenLedCom(com_ledPort.Text);`、`btn_Open.Text = "关闭";` + - 关闭时:只执行 `adam4150Helper.Close(); btn_Open.Text="开启";`,**LED 串口没有关**。 +11. `btn_get_Click`("获取"按钮): + ```csharp + ResModel = DeviceCtrlHelper.adam4150Helper.SetData();//获取数字量采集器ADAM 4150的最新数据 + LED_MSG = ""; + txt_time.Text = DateTime.Now.ToString(); + txt_fire.Text = "正常"; txt_smoke.Text = "正常"; + if (ResModel.fireValue) + { + txt_fire.Text = "发现火情"; + LED_MSG += "发现火情"; + RecordClass.record[RecordClass.recoudeindex, 0] = txt_time.Text;//记录灾情时间至数组 + RecordClass.record[RecordClass.recoudeindex, 1] = " 发现火情";//记录灾情内容至数组 + RecordClass.recoudeindex++; + } + if (ResModel.smokeValue) + { + txt_smoke.Text = "发现烟情"; + LED_MSG += "发现烟情"; + RecordClass.record[RecordClass.recoudeindex, 0] = txt_time.Text; /记录灾情时间至数组 + RecordClass.record[RecordClass.recoudeindex, 1] = " 发现火情";//记录灾情内容至数组 + RecordClass.recoudeindex++; + } + string ResStr = DeviceCtrlHelper.ledHelper.SendLedString(LED_MSG); + if (!(ResStr.IndexOf("Error") >= 0)) + led1.setLedText(ResStr); //推送灾情信息至LED显示屏 + ``` + 这段有三个问题: + - 烟雾分支记录的内容写成了"发现火情",是 bug,参考项目里也一样。 + - 烟雾分支的注释只有一个 `/`,书上的代码编译不过,参考项目正确。 + - 数组只有 50 条,没有越界检查。 +12. "历史灾情信息"按钮:`private void OpenRecord_Click(...) { Form2 fr2 = new Form2(); fr2.Show(); }`。这个按钮和"获取"按钮 btn_get 都不在表5-6 里。 +13. 按图5-10建 Form2(表5-7)。 +14. `Form2_Load`:`for (int i = 0; i < RecordClass.recoudeindex;i++ ) listBox1.Items.Add(RecordClass.record[i,0]+" "+RecordClass.record[i,1]);`。**书里代码用的是 listBox1,表5-7 和参考项目里都叫 lstbRecored。** +15. 运行:点"开启"后效果见图5-11,LED 区显示大字"发现火情",时间 2024/1/23 10 40.51,LED 用 COM1,环境采集用 COM2;点"历史灾情信息"后见图5-12,列表一行一条,如 `2024/1/23 10:41:30 发现火情`。 + +**表5-6 火焰烟雾采集窗体界面控件属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | 火焰烟雾 | | +| com_ledPort | 下拉列表框 | | | 输入/显示LED串口 | +| com_environmentPort | 下拉列表框 | | | 输入/显示ADAM4150串口 | +| btn_Open | 按钮 | Text | 开启 | | +| txt_time | 文本框 | | | 显示时间 | +| txt_fire | 文本框 | | | 显示火焰信息 | +| txt_smoke | 文本框 | | | 显示烟雾信息 | +| groupBox1 | 组合框 | Text | 配置 | | +| lbl_Ledport | 标签 | Text | LED | | +| lbl_4150port | 标签 | Text | 环境采集 | | +| lbl_time | 标签 | Text | 时间 | | +| lbl_fire | 标签 | Text | 火焰 | | +| lbl_smoke | 标签 | Text | 烟雾 | | +| led1 | 自定义控件 | | | 信息显示在LED控件上 | + +表里没有、参考项目里有的控件: +- btn_get(Text"获取") +- OpenRecord(Text"历史灾情信息") +- groupBox2(Text"数据采集") +- led1 的类型是 `WinFormsDeviceControlLibrary.LED` + +**表5-7 历史灾情记录界面控件属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form2 | 窗体 | Text | 历史灾情记录 | | +| lstbRecored | 列表框 | | | 显示灾情记录 | + +**模拟器需要支持**: +- 多窗体 `new Form2().Show()`,以及多个类文件(RecordClass)。 +- `DeviceCtrlHelper.ledHelper.OpenLedCom(port)` 和 `SendLedString(string)`。后者返回字符串,出错时字符串里含"Error"。 +- `led1.setLedText(string)`。 +- `ADAM4150ResModel` 的 fireValue 和 smokeValue。 + +**知识提炼**:continue 语句。 +- 作用:结束本次循环,开始下一次循环。只能用在 while、do/while、for、foreach/in 的循环体里,用在别处会出错。 +- 示例找出1到20内不能被3整除的数:`string StrMsg = ""; for (int i = 1; i <= 20; i++) { if (i % 3==0) continue; StrMsg += i.ToString() + "\n"; } MessageBox.Show(StrMsg);` + +**能力拓展·边想边做**: +> 通过"任务实施"的操作步骤,已经实现了获取火焰及烟雾传感器数据的功能。本环节将实现实时获取相关数据,在发现灾情时推送信息至LED屏并打开报警灯,直至灾情解除。请根据下列提示,实现上述功能。 + +提示1:根据以下提示,完善"GetData"方法,进入代码编写窗口,输入完整代码并调试。 +```csharp +private void GetData() +{ + while (true) + { + if ( ____ )//判断是否有烟雾或火焰报警 + { + txt_time.Text = DateTime.Now.ToString(); + txt_even.Text = "发现灾情"; + btn_remove.Visible = true; + if( ____ )//判断LED灯与报警灯的状态是否为false + { + ____ + ____ + LEDState = true; + AlarmLampState = true; + } + if(LedDisplay==false) + { + ____ //打开LED显示屏串口 + ____ //推送灾情信息至LED显示屏 + LedDisplay = true; + } + btn_open.Visible = true; + thd.Suspend(); + } + else + { + txt_time.Text = DateTime.Now.ToString(); + txt_even.Text = "正常"; + } + Thread.Sleep(2000); + } +} +``` +提示2:根据以下提示,完善"btn_remove"按钮控件的"Click"事件,进入代码编写窗口,输入完整代码并调试。 +```csharp +private void btn_remove_Click(object sender, EventArgs e) +{ + txt_even.ForeColor = Color.Black; + btn_remove.Visible = false; + lbl_alarm.Visible = false; + ____ //重新开启thd实时采集线程 + ____ //结束thdAlarm线程 +} +``` +这道题沿用了任务2 的线程结构(thd、thdAlarm、txt_even、btn_remove、lbl_alarm),还要加 LEDState、AlarmLampState、LedDisplay 三个 bool。"LED灯"应该是接在 adam4150 DO1/DO2 上的 LED 灯(对应项目4 任务3 里 switch 的 "LED1""LED2"),不是 LED 显示屏。任务3 的控件表里这些都没有。 + +**自主探究**: +> 模拟智慧校园灾情监测系统的历史灾情显示功能,运行效果如图5-13所示,需具有如下功能: +> 1)在同一界面内利用数组记录灾情信息。 +> 2)每隔10min将最新5条灾情信息推送至LED显示屏。 + +**参考界面(图5-13)**:标题"火焰烟雾"。左上是时间、火焰、烟雾三个文本框;右上 GroupBox"配置"有"L E D"(COM2)、"环境采集"(COM3)和"关闭"按钮。下面 GroupBox"历史记录"是一个大的多行列表,内容如 `发现火情 发现烟情 发现时间:2017/1/17 15.48:08`、`发现火情 发现时间:2017/1/17 15:48:10`…… + +### 项目5【项目评价】PDF 113 / 书 110 + +| 任务 | 评价指标 | 具体要求 | +|---|---|---| +| 任务1 | 窗体界面 | 窗体控件正确,布局美观 | +| | 开关功能 | 能正确打开、关闭串口;开启、停止定时器;更改按钮显示文字 | +| | 实时显示 | 能实时显示火焰传感器数据 | +| 任务2 | 窗体界面 | 窗体控件正确,布局美观 | +| | 开关功能 | 按钮能打开、关闭串口;开启、停止线程;更改按钮显示文字 | +| | 实时显示 | 能正确利用线程实时显示烟雾传感器数据 | +| 任务3 | 窗体界面 | 窗体控件正确,布局美观 | +| | 开关功能 | 能正确打开、关闭报警灯 | +| | 数组使用 | 能利用数组实时存储数据 | +| | 灾情实时显示 | 能实时显示火焰、烟雾传感器数据 | +| | 灾情推送 | 能正确推送灾情信息,并保证不重复推送 | +| 职业素养 | 代码书写规范 | 应该遵从结构化的要求,采用缩进的格式 | +| | 注释书写规范 | 做到清晰,准确地描述内容。对于程序中复杂的部分必须有注释加以说明。注释量要适中,避免过多或过少导致阅读困难 | +| | 变量命名规范 | 选择有意义的英文进行命名,使变量名能清楚地表达其含义。尽量少使用人名、地名和汉语拼音。应使用那些约定俗成的缩写。多个单词组成的变量名,每个单词的首字母应该大写 | + +(没有分值。PDF 114 / 书 111 是个人学习总结空表。) + +--- + +## 项目6 智慧校园系统——数据存储模块(PDF 115 起 / 书 113 起) + +**项目概述**:数据是物联网的核心驱动力,数据存储是智慧校园系统开发的重要组成部分。本项目把各传感器采集的数据存进数据库,实现存储和分析处理。数据库用 Microsoft SQL Server 2008 R2,结合 C# 编程实现采集数据的存储、更新、删除、修改等基本操作。 + +**学习目标**: +- 理解数据库和数据表的基本概念,包括数据类型、表、字段、主键等概念。 +- 了解数据库设计的流程和关键点。 +- 掌握数据库SQL的基本语法。 +- 能够通过数据库对各类型物联网传感器采集的数据进行存取。 +- 能够在实际操作中连接摄像头,实现对摄像头开关和截图的操作。 +- 树立数据安全保密意识,依规保护数据安全。 +- 增强团队协作沟通能力,共同推动项目进展。 + +### 任务1 制作环境数据表(PDF 115–121 / 书 113–119) +**任务描述**:SQL Server 2008 R2 是微软的关系型数据库管理系统,它的数据库引擎为关系型数据和结构化数据提供更安全可靠的存储。本任务熟悉用数据库软件创建数据库和数据表,并能手动往表里添加、修改数据。学生要学会用 SQL Server 2008 R2 建库建表。 + +**任务实施(全程在 SSMS 里操作,没有 C# 代码)**: +1. 安装 SQL Server 2008 R2,打开 SQL Server Management Studio 并登录,主界面见图6-1(对象资源管理器里有:数据库、安全性、服务器对象、复制、管理、SQL Server 代理)。 +2. 在"对象资源管理器"里右击"数据库",选"新建数据库",名称填 **"环境数据表"**,其余默认,点确定(图6-2/6-3)。新建数据库对话框里:所有者"<默认值>","使用全文索引"打勾;数据文件初始 3MB、按 1MB 增长、不限制;日志文件初始 1MB、按 10% 增长。 +3. 展开"环境数据表"数据库,右击"表",选"新建表"(图6-4/6-5)。 +4. 列名填 `light`、`temperature`、`humidity`,数据类型都选 `varchar(MAX)`,"允许 Null 值"打勾(图6-6)。 +5. 点"保存",在"选择名称"对话框填表名 **"EnvironmentWork"**(图6-7)。 +6. 右击表 EnvironmentWork,选"编辑前200行",就能直接录入数据(图6-8)。右键菜单里还有:新建表、设计、选择前1000行、编写表脚本为、查看依赖关系、全文索引、存储、策略、方面、启动PowerShell、报表、重命名、删除、刷新、属性。 +7. 在 EnvironmentWork 里录入一周的环境数据,light 是光照、temperature 是温度、humidity 是湿度,内容自定。 + +**对应的建表 SQL(书里没有,模拟器可以内置)**: +```sql +CREATE DATABASE 环境数据表; +CREATE TABLE EnvironmentWork (light varchar(MAX) NULL, temperature varchar(MAX) NULL, humidity varchar(MAX) NULL); +-- 没有主键,也没有时间列 +``` + +**知识提炼**: +1. 数据库(database,DB):长期存储在计算机内、有组织、可共享的数据集合,按一定的数据模型组织、描述和存储,冗余度小、数据独立性高、容易扩展,可以在一定范围内供多个用户共享。 +2. 数据库管理系统(DBMS):用户和操作系统之间的一层数据管理软件(如 SQL 数据库管理系统),用来建立、使用和维护数据库,统一管理和控制数据库,保证安全性和完整性。 +3. 数据表:数据库的组成部分,是实际存储数据的地方,每个数据库都有自己的数据表。表由行和列组成,建表就是定义列的过程。 +4. 字段与记录:表里的每一行叫一条记录,表示所存物体各方面的属性;每个数据项叫字段,表示物体某方面的性质,比如学生信息里的学号、姓名、出生日期。 +5. 主键:能唯一标识表中每一行,可以是一个字段,也可以是多个字段的组合,用来强制实体完整性。一个表只能有一个主键,主键列不能为空。例如学生表(学号、姓名、性别、班级)中学号唯一,就是主键。 +6. SQL 数据类型:SQL Server 有 20 多种系统数据类型,插入的值超出允许范围就会报错。常用的有: + 1) bit:只能是0、1或空值 + 2) tinyint:0~255,占1B + 3) smallint:-32768~32767,占2字节 + 4) int:-2147483648~2147483647,占4B + 5) money:-9220亿~9220亿,精确到货币单位的万分之一 + 6) float:-1.79×10^308~1.79×10^308,是近似值 + 7) smalldatetime:精确到1min + 8) datetime:精确到1/300s + 9) uniqueidentifier:GUID 全局唯一标识 + 10) char:定长、非统一编码,必须指定列长 + 11) varchar:变长,必须指定最大长度 + 12) text:最多 2^31-1(约20亿)个字符 + 13) nvarchar:统一编码、变长,能存4000个字符,占用字节翻倍 + 14) ntext:2^30-1(近10亿)个字符,占用字节翻倍 + 15) binary:最多8000B的定长二进制 + 16) varbinary:最多8000B的变长二进制,内容大小会变时用它 + 17) image:变长二进制,最多 2^31-1(约20亿)B + +**能力拓展·边想边做**: +> 在数据库"环境数据表"中建立表"EnvironmentWorkNew",主键为id,整型,可以自增,"EnvironmentDay"为日期型date,"EnvironmentWork"为varchar(200),如图6-9所示。 + +图6-9 的表结构:id int 主键、不允许 Null;EnvironmentDay date 允许 Null;EnvironmentWork varchar(200) 允许 Null。自增要设 IDENTITY(1,1),对应 SQL 为 `CREATE TABLE EnvironmentWorkNew (id int IDENTITY(1,1) PRIMARY KEY, EnvironmentDay date NULL, EnvironmentWork varchar(200) NULL);`。 + +**自主探究**: +> 使用SQL语句,为新表"EnvironmentWorkNew"添加一行内容,"EnvironmentDay"的值为日期"2017年9月","EnvironmentWork"的值为"环境适宜"。 + +"2017年9月"不是完整的 date 值,参考答案应写成类似 `INSERT INTO EnvironmentWorkNew (EnvironmentDay, EnvironmentWork) VALUES ('2017-09-01', '环境适宜');`。模拟器的 SQL 引擎要支持 date 类型和 IDENTITY。 + +### 任务2 存储校园环境数据(PDF 121–125 / 书 119–123,范围在任务中间结束) +**任务描述**:动态采集环境数据,用程序代码存进 SQL 数据库。学生要了解 ADO.NET,学会表内容的添加、删除、修改。 + +**任务实施步骤**: +1. 在"环境数据表"库里新建 **"CampusEnvironment"** 表,共5个字段:自增主键 ID,light(光照度)、humidity(湿度)、temperature(温度)三个都用 `varchar(50)`,addtime 用 `Datetime`。对应 SQL:`CREATE TABLE CampusEnvironment (ID int IDENTITY(1,1) PRIMARY KEY, light varchar(50), humidity varchar(50), temperature varchar(50), addtime datetime);` +2. 把 ADAM4017 的串口直接接到 PC(**书上把 ADAM4017 叫成"数字量采集器",其实它是模拟量采集器**)。注:没有硬件的读者跳过这一步。 +3. 新建"实时存储校园数据"项目,保存在 `D:\物联网编程与应用\项目6\任务2`。 +4. 复制 ADAM4017 的 DeviceCtrlLibrary.dll 和 API 到 `...\实时存储校园数据\实时存储校园数据\bin\Debug\`。 +5. 添加引用(仿真注释同上)。 +6. 按图6-10建主窗体(表6-1),标题"校园灾情数据存储": + - 左上 GroupBox"配置":串口下拉框、"开启"按钮。 + - 右上 GroupBox"数据显示":光照度、温度、湿度三个文本框,"获取""保存""修改""删除"四个按钮。 + - 下方 GroupBox"历史记录":一个大 DataGridView。 +7. 引用: + ```csharp + using System.Data.SqlClient; //引用数据库操作库 + using System.IO.Ports; + using DeviceCtrlLibrary; + using DeviceCtrlLibrary.Model;//引用ADAM4017ResModel + ``` +8. 7个全局变量: + ```csharp + string sqlConnectString = @"Data Source= PC-20150908SCJF\SQLSERVER1026;Initial Catalog=环境数据表;User ID=sa;Password=123456";//数据库的连接字符串 + SqlConnection mySqlConnection; + SqlCommand mySqlCmd; + SqlDataAdapter sqldata; + bool IsOpen = false; + ADAM4017ResModel ResModel; + DataSet ds; + ``` + **小贴士**:其中"PC-20150908SCJF\SQLSERVER1026"为自己SQL数据库的名称,也可以用"."号来表示本机默认数据库名称,该代码采用"sa"用户名来连接数据库,密码为"123456",请根据实际情况修改数据库名称和"sa"的密码。 +9. `Form1_Load`:`mySqlConnection = new SqlConnection(sqlConnectString); mySqlConnection.Open();`,再用 foreach 填充 com_port。(**Load 里没调用 Databind(),所以刚启动时表格是空的。**) +10. `btn_Open_Click`:`if(!IsOpen){ DeviceCtrlHelper.adam4017Helper.Open(com_port.Text); btn_Open.Text="关闭"; IsOpen=True; } else { DeviceCtrlHelper.adam4017Helper.Close(); btn_Open.Text="开启"; IsOpen=False; }`(**书上的 True/False 是大写,引号也是全角 ”关闭”,原样都编译不过**)。 +11. `btn_get_Click`(书上把 `private void` 印成了 `privatevoid`):`ResModel = DeviceCtrlHelper.adam4017Helper.SetData(); txt_light.Text = ResModel.lightValue; txt_humidity.Text = ResModel.humidityValue; txt_temperature.Text = ResModel.temperatureValue;`。这三个值都是 string。 +12. 数据绑定函数 `Databind()`: + ```csharp + mySqlConnection = new SqlConnection(sqlConnectString); + mySqlConnection.Open(); + SqlCommand sqlcmd = new SqlCommand("select ID as '编号',temperature as '温度',humidity as '湿度',light as '光照度', addtime as '时间' from CampusEnvironment", mySqlConnection); + sqldata = new SqlDataAdapter(sqlcmd); + ds = new DataSet(); + sqldata.Fill(ds); + dataGridView1.DataSource = ds.Tables[0].DefaultView; + ``` + **小贴士**:select语句主要用于从表中选取数据。 + (每次调用都新开一个连接,从不关闭。) +13. `btn_save_Click`: + ```csharp + string light = txt_light.Text; string temperature = txt_temperature.Text; string humidity = txt_humidity.Text; + try + { + mySqlCmd = new SqlCommand("insert into CampusEnvironment values (" + temperature + "," + humidity + "," + light + ", '"+DateTime.Now.ToString()+"')", mySqlConnection); + mySqlCmd.ExecuteNonQuery(); + MessageBox.Show("保存成功!"); + Databind(); + } + catch (Exception ex) { MessageBox.Show("保存失败:"+ex.ToString()); } + ``` + **问题**: + - INSERT 没写列名,值的顺序是 temperature、humidity、light,第1步建表的列顺序却是 light、humidity、temperature,数据会存错列。除非学生建表时按 temperature、humidity、light 的顺序。模拟器的 SQL 引擎必须严格按列顺序插入,才能让这个错误暴露出来,或者在题目说明里提醒学生。 + - 数值直接拼进 SQL、没加引号,存 varchar 列时靠隐式转换;值一旦不是数字就会报错。 + - `DateTime.Now.ToString()` 生成的字符串要能被 SQL 解析成 datetime。 +14. `btnUpdate_Click`: + ```csharp + SqlCommandBuilder cmdb = new SqlCommandBuilder(sqldata); + sqldata.UpdateCommand = cmdb.GetUpdateCommand(); + if (sqldata.Update(ds.Tables[0]) == 1) { MessageBox.Show("修改成功!"); Databind(); } + else { MessageBox.Show("修改失败!"); } + ``` + 做法是让用户直接在 DataGridView 里改单元格,再提交。需要:DataGridView 可编辑并回写到 DataTable;SqlCommandBuilder 能根据带别名的 SELECT 和主键 ID 生成 UPDATE;`== 1` 表示只有恰好改了一行才算成功。 +15. `btnDelete_Click`: + ```csharp + try + { + mySqlCmd = new SqlCommand("delete from CampusEnvironment where id="+dataGridView1.CurrentRow.Cells[0].Value.ToString(), mySqlConnection); + mySqlCmd.ExecuteNonQuery(); + MessageBox.Show("删除成功!"); + Databind(); + } + catch (Exception ex) //捕获数据库操作异常信息 + { + ``` + **范围到这里结束**。后面的 catch 内容在下一段里,参考代码是 `MessageBox.Show("修改失败:" + ex.ToString());`。 + +**表6-1 校园数据存储窗体界面控件属性** + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | 校园灾情数据存储 | | +| groupBox1 | 组合框 | Text | 数据显示 | | +| groupBox2 | 组合框 | Text | 配置 | | +| groupBox3 | 组合框 | Text | 历史记录 | | +| txt_light | 文本框 | | | 输入/显示光照度值 | +| txt_temperature | 文本框 | | | 输入/显示温度值 | +| txt_humidity | 文本框 | | | 输入/显示湿度值 | +| btn_get | 按钮 | Text | 获取 | 控制数据获取 | +| btn_save | 按钮 | Text | 保存 | 保存数据到SQL数据库 | +| btnUpdate | 按钮 | Text | 修改 | 更新修改的数据 | +| btnDelete | 按钮 | Text | 删除 | 删除修改的数据 | +| btn_Open | 按钮 | Text | 开启 | 打开串口 | +| com_port | 下拉列表框 | | | 显示串口 | +| lbl_light | 标签 | Text | 光照度 | | +| lbl_temperature | 标签 | Text | 温度 | | +| lbl_humidity | 标签 | Text | 湿度 | | +| dataGridView1 | 数据网格控件 | | | 用于绑定数据库数据 | + +--- + +## 书和参考项目的差异汇总(只列本段范围内的) + +| 位置 | 书中 | 参考项目 / 实际情况 | +|---|---|---| +| 项目3 任务2 RadioButton 示例 | 步骤说"Click"事件 | 代码是 CheckedChanged(书里的示例,参考项目里没有) | +| 项目3 任务2 CheckBox 示例 | 表3-8 控件名 checkBox_Bold/Italic/Underline/Strikeout | 代码用 checkBox1–4 | +| 项目3 能力拓展 Split 提示 | spstring[2]="ed" | 应为 "ef" | +| 项目3 能力拓展 ?: 提示 | `5>3 ? True:False` | C# 要写小写 true/false | +| 项目4 任务1 | 表4-1 里标签名 lbl_port/lbl_time/lbl_even,没有 GroupBox | 参考项目是 label1/label2/label3,还有 groupBox1"数据显示" | +| 项目4 任务1/3 | `boolIsOpen = false;` | `bool IsOpen = false;` | +| 项目4 任务2 | `using System.IO.Ports;` | 参考项目是 `using System.IO;`(Directory 要用它) | +| 项目4 任务2 | label1 Text"摄像机IP" | 参考项目里是"摄像头IP" | +| 项目4 任务3 | 表4-4 控件名"Alarm" | 实例名 alarm1,类型 WinFormsDeviceControlLibrary.Alarm | +| 项目5 任务1/2 | 表5-1/5-5 标签名 lbl_port/lbl_time/lbl_even | 参考项目是 label1/2/3,加 groupBox1"数据显示" | +| 项目5 任务2 FormClosing | `thd.Abort();` | `if(thd.ThreadState==ThreadState.Unstarted) thd.Abort();` | +| 项目5 任务3 Form1_Load | for + continue 排除 COM1 | 两个 foreach,不排除 COM1 | +| 项目5 任务3 btn_get 烟雾分支 | 注释只有一个 `/`;内容记成" 发现火情" | 参考项目注释正确,但同样记成" 发现火情" | +| 项目5 任务3 Form2_Load | `listBox1.Items.Add` | 参考项目是 `lstbRecored.Items.Add`,和表5-7 一致 | +| 项目5 任务3 表5-6 | 没有 btn_get、OpenRecord、groupBox2 | 参考项目里有(获取 / 历史灾情信息 / 数据采集);Form1 里还有空的 com_ledPort_Click、com_environmentPort_Click、button1_Click | +| 项目6 任务1 | 全部是 SSMS 操作(建库"环境数据表"、建表 EnvironmentWork),没有代码 | 参考项目 `项目6/任务一` 却是一个 WinForms 程序:连接串 `Password=123`,往 CampusEnvironment 插入3个值、不含 addtime。它像是任务2 的早期版本,和书上的任务1 对不上 | +| 项目6 任务2 连接串 | `Data Source= PC-20150908SCJF\SQLSERVER1026;…Password=123456` | 参考项目是 `Data Source=.;…Password=123456` | +| 项目6 任务2 Form1_Load | 打开连接、填充 com_port,不调 Databind | 参考项目**只调 Databind(),不填 com_port**,串口下拉框一直是空的 | +| 项目6 任务2 Databind | 别名 `light as '光照度'` | 参考项目是 `'光照'`;表6-1 标签"光照度",参考项目 lbl_light 是"光照" | +| 项目6 任务2 | groupBox3 历史记录 | 参考项目叫 groupBox4 | +| 项目6 任务2 btn_Open | `IsOpen=True/False`,全角引号 | 参考项目是小写 true/false,代码正确 | +| 项目6 | 没有 SQL 脚本 | 配套资源里也没有 .sql/.mdf,模拟器要自己预置这些表 | + +## 模拟器需要支持的、光看代码不明显的东西(本段范围) +- **WinForms 控件**: + - RadioButton、CheckBox:Checked 属性和 CheckedChanged 事件;CheckBox 还有 ThreeState、Appearance。 + - PictureBox:SizeMode 的 StretchImage/Zoom 等模式,BackColor,`Image.FromFile`,`Image.Save(path)`。 + - ListBox,DataGridView(可编辑,CurrentRow.Cells[0].Value)。 + - GroupBox,Timer(Interval 最大要能到 7200000),以及 Visible、Enabled、ForeColor 的动态切换(用于闪烁效果)。 + - `Font(Font, FontStyle)` 和 FontStyle 的位运算。 + - 多个窗体(Form2.Show()),多个 .cs 文件(RecordClass,带 static 字段)。 + - 窗体的 FormClosing 事件。 + - MessageBox.Show(text, caption)。 +- **自定义控件(WinFormsDeviceControlLibrary)**:`Alarm.OnOff(bool)`、`LED.setLedText(string)`,项目3 用 `Lamp.OnOff(bool)`。这个控件库要先"添加到工具箱"才能用。 +- **设备 API(DeviceCtrlLibrary)**: + - adam4150Helper(数字量): + - 方法:`Open(port)`、`Close()`、`SetData()`、`OnOff(int DO, bool)`。 + - SetData() 返回 ADAM4150ResModel,字段有 infraredValue(DI3)、fireValue(DI1)、smokeValue(DI2)。 + - 继电器 DO0 是报警灯,DO1、DO2 在代码里叫 LED1、LED2。 + - adam4017Helper(模拟量):`Open/Close/SetData()`,SetData() 返回 ADAM4017ResModel,有 lightValue、humidityValue、temperatureValue 三个 string。 + - ipCameraHelper: + - 方法:`Open(ip, "admin", "")`、`Start()`、`Stop()`、`PanUp/PanDown/PanLeft/PanRight()`。 + - 事件:`ImageRead += handler(ImageReadEvenArgs e)`,用 `e.img` 取图。 + - 这个事件回调要能直接给 pictureBox1.Image 赋值(书里没做 Invoke)。 + - ledHelper:`OpenLedCom(port)`;`SendLedString(msg)` 返回字符串,出错时含"Error"。 + - 命名空间 `DeviceCtrlLibrary.Model`(ADAM4150ResModel、ADAM4017ResModel)。 +- **串口和硬件**: + - `SerialPort.GetPortNames()` 要返回一组虚拟串口。项目5 任务3 会排除 COM1,书上的截图里却用了 COM1/COM2/COM3,所以至少要提供 COM1–COM3。 + - 串口服务器、路由器的配置只是硬件准备步骤,模拟器里不用体现。 + - 教室A/B 那类拓展题需要同时把 DI1、DI2 当两路火焰(或两路烟雾)传感器,所以模拟器里每个 DI 接什么传感器最好能配置。 +- **线程**: + - Thread 的 Start、Suspend、Resume、Abort、Sleep,ThreadState。 + - `Control.CheckForIllegalCrossThreadCalls = false`:后台线程直接改 UI 也要能生效。 +- **数据库(ADO.NET + T-SQL 子集)**: + - ADO.NET 对象:SqlConnection(连接串).Open(),SqlCommand.ExecuteNonQuery(),SqlDataAdapter.Fill(DataSet),`ds.Tables[0].DefaultView` 作为 DataSource,`SqlCommandBuilder.GetUpdateCommand()` 和 `adapter.Update(DataTable)`(返回受影响行数)。 + - SQL 语句:带中文别名的 `select ... as '编号' ...`,不写列名的 `insert into T values (...)`,`delete from T where id=...`。 + - 类型和库名:varchar(MAX)、varchar(50)、varchar(200)、date、datetime、int IDENTITY 主键;中文数据库名"环境数据表"。 + - 连接串:服务器名、sa 密码这些要能宽松接受,不管写的是 `.` 还是 `PC-…\SQLSERVER1026` 都映射到学生自己的沙箱库。 + - 任务1 的 SSMS 操作(新建数据库、新建表、编辑前200行)需要一个简化的网页版"表设计器 + 数据编辑"界面,或者改成让学生写 SQL。 diff --git a/docs/教材摘要/3-项目6后半到项目8.md b/docs/教材摘要/3-项目6后半到项目8.md new file mode 100644 index 0000000..7e9a4c0 --- /dev/null +++ b/docs/教材摘要/3-项目6后半到项目8.md @@ -0,0 +1,882 @@ +# 《物联网编程与应用 第2版》摘要 第3部分(PDF 第126–187页) + +## 页码对照 + +| PDF 页 | 书页 | 内容 | +|---|---|---| +| 126–127 | 124–125 | 项目6 任务2(中途开始)末尾:步骤16运行结果;知识提炼 1–4 | +| 128–129 | 126–127 | 项目6 任务2 知识提炼5、能力拓展;项目6 任务3 开始 | +| 130–132 | 128–130 | 项目6 任务3 实施 + 知识提炼 | +| 133 | 131 | 项目6 项目评价 | +| 134 | 133(无页码) | 项目7 首页 + 任务1 任务描述(书第132页应是空白页,扫描件里没有) | +| 135–141 | 134–140 | 项目7 任务1 | +| 142–149 | 141–148 | 项目7 任务2(PDF 149 / 书148 上半页是任务2的自主探究) | +| 149–156 | 148–155 | 项目7 任务3 | +| 157 | 156 | 项目7 项目评价 | +| 158 | 157(无页码) | 项目8 首页 + 任务1 任务描述 | +| 159–160 | 158–159 | 项目8 任务1;任务2 开始 | +| 160–165 | 159–164 | 项目8 任务2 | +| 165–175 | 164–174 | 项目8 任务3(书174上半页) | +| 175–181 | 174–180 | 项目8 任务4 | +| 182–186 | 181–185 | 项目8 任务5 + 项目评价(书185) | +| 187 | 186 | 项目8 项目评价(续)+ 个人学习总结 | + +页码规则:PDF 126–133 时,书页 = PDF − 2;PDF 134–187 时,书页 = PDF − 1。 +**扫描件到 PDF 187(书186)就结束了,没有「参考文献」页,也没有封底。** + +--- + +## 项目6 智慧校园系统——数据存储模块(只覆盖结尾部分) + +### 任务2(中途开始;任务名称在我负责的范围之前) + +PDF 126 开头是「修改」按钮 Click 代码的结尾 `MessageBox.Show("修改失败:" + ex.ToString()); } }`,接着是**步骤16)运行该程序**,图6-11「运行结果」。窗体标题是「校园灾情数据存储」,各区域如下: +- 「配置」组:串口下拉框(COM1)+「关闭」按钮 +- 「数据显示」组:光照度/温度/湿度文本框,「获取」「保存」「修改」「删除」按钮 +- 「历史记录」组:DataGridView,列为 编号/温度/湿度/光照度/时间 + +从项目评价表看,这个任务的内容是显示光照度、温度、湿度,并把数据存进数据库。参考工程是 `项目6/任务二/校园数据存储`,与书上一致:btn_Open / btn_get / btn_save / btnUpdate / btnDelete。 + +#### 知识提炼(书124–126) +1. **insert 插入语句**:`insert into 表名 (列1, 列2, ...) values (值1, 值2, ...);`。示例表 user 有 id(自增主键)、username varchar(50)、birthday date 三个字段: + - 完整插入 `insert into user (id, username, birthday) values (1, '张三', '1999-09-09');` + - 部分插入 `insert into user (username) values ('李四');`(id 自增) + - 简写插入 `insert into user values ('王五', '1910-10-10');`(按字段顺序填写,跳过自增 id) +2. **update 更新语句**:`update 表名 set 列1=新值1, 列2=新值2, ... [where 条件];`。示例:`update user set birthday='2016-01-01' where username='张三';`;还有 T-SQL 特有写法 `update top (3) user set birthday='2000-01-01';` +3. **delete 删除语句**:`delete from 表名 [where 条件];`。示例:`delete from user where username='张三';`、`delete top (5) from user;`、`delete from user;` +4. **ADO.NET 对象模型简介**:ADO.NET 由「.NET 数据提供程序」和「数据集 DataSet」两部分组成。表6-2 ADO.NET 核心组件: + +| 对象 | 说明 | 特点 | +|---|---|---| +| Connection | 建立与特定数据库的连接 | 可以自己创建,也可由其他对象自动产生(如 DataAdapter) | +| Command | 对数据源执行命令 | 通过 Connection 对象下达命令,Connection 的指向决定操作对象 | +| DataAdapter | 从数据源中检索数据并填充 DataSet 中的表 | DataAdapter 填充到 DataSet 中的是断开连接的脱机数据 | +| DataReader | 从数据库中检索只读的数据流 | DataReader 读取数据必须在维持数据库连接的前提下进行 | +| DataSet | 在内存中缓存从数据源中检索到的数据 | 独立于其他数据源 | + +5. **ADO.NET 中的 SQL 操作类** + - SqlConnection:表示一个打开的 SQL Server 连接,不能被继承;OLE DB 数据源要用 OleDbConnection。有两个构造函数:`SqlConnection()` 和 `SqlConnection(String 连接字符串)`。用完后关闭。 + - SqlCommand:执行 T-SQL 语句或存储过程。有四个构造函数:`()`、`(String)`、`(String, SqlConnection)`、`(String, SqlConnection, SqlTransaction)`。 + - SqlDataReader:从上往下读取只读数据。必须通过 `SqlCommand.ExecuteReader()` 创建,不能直接用构造函数。使用期间连接被它独占,必须调用 `Close()` 后连接才能做其他操作。 + +#### 能力拓展 +**边想边做**(书126): +> 通过「任务实施」的操作步骤,对已经实现了的环境数据添加一个图片框,以获取监控画面,并将环境数据与监控画面每5min添加入数据库。环境数据界面如图6-12所示。 + +图6-12「环境数据界面」,窗体标题「校园灾情数据存储」: +- 左上「数据显示」组:光照度、温度、湿度 3 个文本框;「获取」「保存」按钮 +- 左中「配置」组:串口下拉框 +「开启」按钮 +- 右侧「监控图像采集」组:一块大图片框 +- 底部「历史记录」组:DataGridView(空) + +**自主探究**(书127): +> 在「边想边做」的基础上,为程序增加下列功能:添加一个文本框 TextBox(Name 属性值为 lightValue)用于输入更新条件,当表中「light」的值超过 TextBox 的值时,将数据添加入数据表。 + +### 任务3 查询校园安防数据(书127–130 / PDF 129–132) + +**任务描述**:在任务1、任务2的基础上,实现灾情数据按时间段查询。学生通过这个任务熟悉 select 语句以及 DateTimePicker、DataGridView 两个控件。 + +**任务实施** +1. 按图6-13「查询图片窗体界面」建 Form1(标题「查询校园灾情数据」):顶部一行「开始日期 [DateTimePicker] 结束日期 [DateTimePicker] [查询]」,下面「历史记录」组框里放 DataGridView。 + +表6-3 查询图片窗体界面控件属性: + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| LblStartTime | 标签 | Text | 开始日期 | 开始日期 | +| LblEndTime | 标签 | Text | 结束日期 | 结束日期 | +| StartTime | 日期时间控件 | Datetime | 时间 | 起始时间 | +| EndTime | 日期时间控件 | Datetime | 时间 | 结束时间 | +| btnCx | 按钮 | Text | 查询 | 查询 | +| groupBox4 | 组合框 | Text | 历史记录 | 组合框 | +| dataGridView1 | 数据网格控件 | | | | + +2. 引用 `System.Data.SqlClient`、`System.IO.Ports`。 +3. 声明 5 个全局变量:连接字符串 `sqlConnectString`、`SqlConnection mySqlConnection`、`SqlCommand mySqlCmd`、`SqlDataAdapter sqldata`、`DataSet ds`。 +4. 写 btnCx_Click:打开连接,执行 `select ID as '编号',temperature as '温度',humidity as '湿度',light as '光照度',addtime as '时间' from CampusEnvironment where addtime BETWEEN '' AND ''`,用 `SqlDataAdapter.Fill(ds)` 填充,再 `dataGridView1.DataSource = ds.Tables[0].DefaultView`,最后关闭连接。 +5. 运行,选好起止日期后点「查询」,结果见图6-14(示例数据为 2024/1/16–2024/1/23 共 9 行)。 + +**知识提炼**(书128–130) +1. **select 查询语句**:`SELECT 列名称 FROM 表名称` 或 `SELECT * FROM 表名称`。小贴士:SQL 对大小写不敏感。示例:`SELECT * FROM user`、`SELECT id,username FROM user`。表6-4 where 子句运算符: + +| 运算符 | 描述 | 实例 | +|---|---|---| +| = | 等于 | 查询表中姓名为张三的生日数据:`SELECT * FROM user where username='张三'` | +| <> | 不等于。在 SQL 的一些版本中可写成 != | 查询表中姓名不是张三的生日数据:`SELECT * FROM user where username<>'张三'` | +| > | 大于 | 查询表中生日为2000年后的数据:`SELECT * FROM user where year(birthday)>2000` | +| < | 小于 | 查询表中生日为2000年前的数据:`... where year(birthday)<2000` | +| >= | 大于等于 | 查询表中生日为2000年(含2000)后的数据:`... where year(birthday)>=2000` | +| <= | 小于等于 | 查询表中生日为2000年(含2000)前的数据:`... where year(birthday)<=2000` | +| between | 在某个范围内 | 查询表中生日为2000—2006年之间的数据:`... where year(birthday) between 2000 in 2006`(书上原文就是 `in`,正确写法应为 `and`) | +| like | 搜索某种模式 | 查询表中姓张的数据:`SELECT * FROM user where username like '张%'` | + + 逻辑运算符:AND 要求所有条件都成立,OR 只要一个条件成立即可。 +2. **SqlDataAdapter 类**:DataSet 与 SQL Server 之间的桥接器,通过 Fill(让 DataSet 跟数据源一致)和 Update(让数据源跟 DataSet 一致)工作。 +3. **DataSet 类**:内存中的临时数据库,包含 DataTable、DataView。第 i 行第 j 列的访问方式是 `ds.Tables[0].Rows[i].ItemArray[j]`,索引从 0 开始。 +4. **DataGridView 控件**:表格形式显示和编辑数据;绑定时设置 DataSource,数据源含多个表时再设置 DataMember。 +5. **DateTimePicker 控件**:Format 枚举(DateTimePickerFormat)可取 Long / Short / Time / Custom,选 Custom 时用 CustomFormat 字符串;Text 返回文本,Value 返回 DateTime;MinDate / MaxDate 限制范围;点下箭头弹出日历;ShowUpDown 显示上下箭头。三种常用设置: + - 只显示时间:Format=Time,ShowUpDown=true + - 只显示时分(如 7:30):Format=Custom,CustomFormat=`HH:mm` + - 显示日期和时间:Format=Custom,CustomFormat=`yyyy:MM:dd HH:mm:ss`(书上原文如此) + +任务3没有「能力拓展」。 + +### 项目6 项目评价(书131) + +| 任务 | 评价指标 | 具体要求 | +|---|---|---| +| 任务1 | 功能实现 | 能正确登录数据库;能正确新建数据库;能正确添加数据 | +| 任务2 | 窗体界面 | 界面布局合理,美观大方,符合使用者的习惯 | +| 任务2 | 功能实现 | 能正确显示光照度、温度、湿度;能利用数组正确储存数据 | +| 任务3 | 窗体界面 | 界面布局合理、美观大方,符合使用者的习惯 | +| 任务3 | 功能实现 | 能实现按日期查询灾情数据 | +| 职业素养 | 代码书写规范 | 应该遵从结构化的要求,采用缩进的格式 | +| 职业素养 | 注释书写规范 | 做到清晰、准确地描述内容。对于程序中复杂的部分必须有注释加以说明。注释量要适中,避免过多或过少导致阅读困难 | +| 职业素养 | 变量命名规范 | 选择有意义的英文进行命名,使变量名能大致清楚地表达其含义。尽量少使用人名、地名和汉语拼音。应使用那些约定俗成的缩写。多个单词组成的变量名,每个单词的首字母应该大写 | +| 职业素养 | 界面常用控件命名约定 | 窗体控件命名以控件名称缩写前3个字母开头,以具有代表含义的词来命名,其形式为:控件缩写(前3个字母)+命名(具有代表含义的单词) | +| 职业素养 | 数据库规范 | 符合数据库命名、设计规范,做到SQL编码优化规范 | + +评价表**没有分值**,只有指标和要求,后面跟「个人学习总结」(项目学习情况 / 心得与反思,空白待填)。项目7、项目8 的评价表格式相同。 + +### 项目6 书与参考代码的差异 / 模拟器要点 +- **任务3 书上代码漏了 `ds = new DataSet();`**,直接 `sqldata.Fill(ds)` 会抛空引用异常。参考代码 `项目6/任务三/校园数据存储/Form1.cs` 里有这一行。 +- 列别名:书上是 `light as '光照度'`,参考代码是 `light as '光照'`。 +- 连接字符串:书上是 `Data Source= PC-20150908SCJF\SQLSERVER1026;Initial Catalog=环境数据表;User ID=sa;Password=123456`,参考代码是 `Data Source=.`。数据库名「环境数据表」,表名 CampusEnvironment,字段 ID / temperature / humidity / light / addtime。 +- 模拟器的 SQL 层需要支持:`BETWEEN '日期字符串' AND '日期字符串'`(拼接的是 `DateTime.ToString()`,即中文区域格式 `2024/1/16 14:42:00`)、`as '中文别名'`、`top (n)` 用在 update/delete 上、`year()` 函数、`like '张%'`。另外要支持 SqlCommandBuilder + `DataAdapter.Update`(任务2的「修改」功能),以及 DataGridView 绑定 `DataView`。 +- 书上的 SQL 知识点用的是 T-SQL 方言(`top (n)`、`year()`)。如果后端用 SQLite,需要转换或模拟这些语法。 + +--- + +## 项目7 智慧校园系统——情景控制模块(书133–156) + +**项目概述**:情景控制就是用智能化技术实现自动控制,集成摄像头、温湿度传感器、语音识别器、智能电器等设备。本项目模拟智慧教室、智慧宿舍、智慧会议室三个场景,采集光照度、温湿度等数据,根据时间和环境联动灯光、窗帘、广播。 + +**学习目标**: +- 理解程序项目文件的结构 +- 能根据需求设计交互 UI 窗体,并实现与数据的联动 +- 能用 Button、Timer、TrackBar、PictureBox 等组件对物联网设备做简单控制 +- 培养分析和解决问题的能力 +- 养成耐心细致、严谨扎实的工作作风 +- 增强职业认同感,提升职业素养,树立职业自信心 + +### 任务1 实现智慧教室(书133–140 / PDF 134–141) + +**任务描述**:在上课模式下,根据光照度自动开关灯,根据温度自动开关空调(空调用 TrackBar 模拟)。 + +**任务实施** +1. 把 4017(模拟量采集器)和 4150(数字量采集器)的串口接到 PC(没有硬件可以跳过)。 +2. 新建 WinForms 项目「教室情景模式」,保存到 `D:\物联网编程与应用\项目7\任务1\`。 +3. 把 ADAM4017、ADAM4150 需要的 `DeviceCtrlLibrary.dll` 和「API」文件夹里的所有 dll 复制到 `...\教室情景模式\bin\Debug\`。 +4. 添加引用 DeviceCtrlLibrary.dll。**没有硬件的读者改为添加「仿真动态库文件」,不需要 API 文件夹里的 dll。** +5. 项目属性 → 资源 →「添加资源」,加入 `switch_off.png`、`switch_on.png`。 +6. 按图7-1建窗体(标题「智慧教室情景控制」): + - 顶部「模式选择」组:标签「智慧教室将开启」+「启用」按钮 + - 左侧「采集量」组:串口下拉框,光照度/温度/湿度文本框 + - 右侧「状态栏」组:「空调」+ 开关图片 + TrackBar(两端标 18℃ / 32℃);「灯光」+ 开关图片 + 灯泡控件 + +表7-1 窗体控件属性: + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| frm_control | 窗体 | Text | 智慧教室情景控制 | | +| lbl_mode | 标签 | Text | 智慧教室将开启 | | +| btn_apply | 按钮 | Text | 启用 | 开启、关闭模式 | +| grp_input | 组合框 | Text | 采集量 | | +| lbl_import | 标签 | Text | 串口 | | +| lbl_light | 标签 | Text | 光照度 | | +| lbl_temperation | 标签 | Text | 温度 | | +| lbl_humidity | 标签 | Text | 湿度 | | +| com_import | 下拉列表框 | | | 设置采集串口 | +| txt_light | 文本框 | | | 显示光照度 | +| txt_temperation | 文本框 | | | 显示温度 | +| txt_humidity | 文本框 | Text | | 显示湿度 | +| grp_status | 组合框 | Text | 状态栏 | | +| lbl_light | 标签 | Text | 灯光 | | +| lbl_airconditione | 标签 | Text | 空调 | | +| lamp | 灯泡 | | | 模拟电灯开关 | +| trb_airconditione | 滑动条 | | | 模拟空调温度 | +| lbl_lbl_airconditionmin | 标签 | Text | 18℃ | | +| lbl_airconditionmax | 标签 | Text | 32℃ | | +| pic_lightsStatus | 图片框 | Image | Switch_off.png | 控制并显示电灯状态 | +| pic_airconditioneStatus | 图片框 | Image | Switch_off.png | 显示空调状态 | +| timer | 时间控件 | Interval | 2000 | 实现实时采集 | + +(表中有书上原样的问题:lbl_light 出现了两次,「lbl_lbl_airconditionmin」也是原文;后两行文本框名书上写作 txt_temperation,代码里用的是 txt_temperature。) + +7. 引用 `System.IO.Ports`、`DeviceCtrlLibrary`、`DeviceCtrlLibrary.Model`。 +8. 定义 `bool IsOpen`、`IsLampOpen`、`IsairconditioneOpen`,以及 `ADAM4017ResModel ResModel`。 +9. frm_control_Load:用 `SerialPort.GetPortNames()` 填充 com_import。 +10. btn_apply_Click:第一次点击打开 4017/4150 串口,按钮改为「关闭」,lbl_mode 改为「智慧教室已开启」,启动 timer;再次点击关闭串口、停止 timer、恢复文字。 +11. timer_Tick:调用 `adam4017Helper.SetData()` 采集并显示。光照 <400 时开灯(图片 switch_on、`lamp.OnOff(true)`、`adam4150Helper.OnOff(1,true)`),>800 时关灯。温度 >30 时开空调、trb=26;<5 时开空调、trb=25;其他情况关空调、trb=18。 +12. pic_lightsStatus_Click:手动切换灯。 + +**知识提炼** +1. TrackBar 控件:横向或纵向拖动来设置值,类似系统音量条。 + +表7-2 TrackBar 常见属性: + +| 属性 | 说明 | +|---|---| +| Orientation | 获取或设置轨道方向,默认 Horizontal(横向,左右拖动),可改为 Vertical(纵向,上下拖动) | +| TickFrequency | 获取或设置滑块旁刻度的间隔,默认为1 | +| TickStyle | 获取或设置刻度位置,默认 BottomRight(在底部,从左往右递增),可改为 TopLeft(在顶部,从右往左递增),还有 Both(两种都要)、None(无刻度) | +| Enabled | 获取或设置滑块是否可拖动,默认 true,可改为 false 表示不能拖动 | +| Maximum | 滑块条最大端的值,默认10 | +| Minimum | 滑块条最小端的值,默认0 | +| LargeChange | 长距离移动(鼠标单击轨道让滑块靠近)时每次改变的值,默认5 | +| SmallChange | 鼠标拖动滑块或键盘方向键移动时每跳的最小值,默认1 | +| Value | 当前滑块所表示的值,默认0 | +| Visible | 滑动条是否可见,默认 true,可改为 false 表示隐藏 | + +表7-3 TrackBar 常见事件: + +| 事件 | 说明 | +|---|---| +| Scoll(原文拼写,应为 Scroll) | 用户拖动滑块、按下箭头或单击轨道改变值时触发 | +| ValueChanged | 该事件仅在用户通过拖动滑块更改值时触发(书上原文;实际上代码修改 Value 时也会触发) | + +2. **TrackBar 显示案例**(书中独立的小练习,适合做成平台题目):新建「TrackBar显示案例」项目,保存到 `D:\物联网编程与应用\项目7\任务1\`。窗体(图7-2)中间是一个 TrackBar,左右两端标签 0 和 10,下方一个标签显示当前值。 + +表7-4「TrackBar显示」界面控件的属性: + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| Form1 | 窗体 | Text | TrackBar显示 | | +| trackBar1 | 滑动条 | | | | +| label1 | 标签 | Text | 0 | | +| label2 | 标签 | Text | 10 | | +| label3 | 标签 | Text | 0 | 显示当前TrackBar控件对应的数值 | + + 代码:`private void trackBar1_Scroll(...) { label3.Text = Convert.ToString(trackBar1.Value); }`。运行后拖动滑块,label3 显示 6 等数值(图7-3)。 + +**能力拓展** + +**边想边做**(书139–140): +> 通过「任务实施」的操作步骤,已经实现了智慧教室上课模式的基本功能。现在在此基础上实现考试模式。通过添加考试模式,要求在考试模式下窗帘全开(用 TrackBar 模拟开关,0 为全开,10 为全关),光照度不够 200 时开启电灯。请根据下列提示,实现上述功能。 +> 提示:根据以下提示,完善 timer_Tick 事件。 + +```csharp +private void timer_Tick(object sender, EventArgs e) +{ + ResModel = DeviceCtrlHelper.adam4017Helper.SetData();//采集数据 + txt_light.Text = ResModel.lightValue; + txt_temperature.Text = ResModel.temperatureValue; + txt_humidity.Text = ResModel.humidityValue; + if(com_mode.Text=="上课模式")//上课模式,与任务实施部分相同 + { + if (Convert.ToInt16(ResModel.lightValue) < 200)//光照度过低则打开灯 + { + pic_lightsStatus.Image = Properties.Resources.switch_on; + IsLampOpen = true; + lamp.OnOff(true);//打开界面灯 + DeviceCtrlHelper.adam4150Helper.OnOff(1,true);//打开1通道灯 + } + else if (Convert.ToInt16(ResModel.lightValue) > 800 ) + { + pic_lightsStatus.Image = Properties.Resources.switch_off; + IsLampOpen = false; + lamp.OnOff(false);//关闭界面灯 + DeviceCtrlHelper.adam4150Helper.OnOff(1, false);//关闭1通道灯 + } + if (Convert.ToInt16(ResModel.temperatureValue) > 30 ) + { + pic_airconditioneStatus.Image = Properties.Resources.switch_on; + IsairconditioneOpen = true; + trb_airconditione.Value = 26; + } + else if (Convert.ToInt16(ResModel.temperatureValue) < 5) + { + pic_airconditioneStatus.Image = Properties.Resources.switch_on; + IsairconditioneOpen = true; + trb_airconditione.Value = 25; + } + else + { + pic_airconditioneStatus.Image = Properties.Resources.switch_off; + IsairconditioneOpen = false; + trb_airconditione.Value = 18; + } + } + else if (____)// 判断com_mode控件选择的"考试模式" + { + ____; //将窗帘设置为0全开(trackBar 控件名为trb_curtain) + if (____)//光照度低于200则打开灯 + { + pic_lightsStatus.Image = Properties.Resources.switch_on; + IsLampOpen = true; + lamp.OnOff(true);//打开界面灯 + DeviceCtrlHelper.adam4150Helper.OnOff(1, true);//打开1通道灯 + } + else if (____) //光照度大于400 + { + pic_lightsStatus.Image = Properties.Resources.switch_off; + IsLampOpen = false; + lamp.OnOff(false);//关闭界面灯 + DeviceCtrlHelper.adam4150Helper.OnOff(1, false);//关闭1通道灯 + } + } +} +``` +说明:这道练习隐含要求在窗体上新增下拉框 `com_mode`(选项「上课模式」「考试模式」)和窗帘滑动条 `trb_curtain`。另外,提示代码中上课模式的开灯阈值是 <200,任务实施里是 <400;提示代码的上课模式分支漏了 `adam4150Helper.OnOff(1,false)`,书上原样如此。 + +**自主探究**(书140): +> 在「边想边做」的基础上,为程序增加以下功能:若当前环境实时温度低于5℃,则自动关闭窗户(使用 TrackBar 控件模拟)并打开空调,设置为25℃。 + +### 任务2 实现智慧寝室(书141–148 / PDF 142–149) + +**任务描述**:寝室有四种情景模式,根据采集的光照、温湿度和定时自动切换: +- 起床模式:开灯光,开广播放音乐,窗帘拉到半开 +- 上课模式:关灯光和广播,窗帘全开 +- 熄灯模式:关灯光、广播和窗帘 +- 回寝模式:开灯光,关窗帘,开广播放音乐 + +**任务实施** +1–4. 与任务1相同,项目名「寝室情景模式」,保存到 `D:\物联网编程与应用\项目7\任务2\`。没有硬件就用仿真 dll。 +5. 添加资源 `switch_off.png`、`switch_on.png`、`ring.mp3`。 +6. 按图7-4建窗体(标题「智慧寝室情景控制」): + - 顶部「模式选择」组:「当前时间」文本框 +「开启情景模式」按钮 + - 左侧「采集量」组:串口、光照度、温度、湿度 + - 右侧「状态栏」组:「窗帘」TrackBar(两端标「全开」「全关」)、「广播」开关图片、「灯光」灯泡 + +表7-5 窗体界面控件属性: + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| frm_control | 窗体 | Text | 智慧寝室情景控制 | | +| grp_mode | 组合框 | Text | 模式选择 | | +| lbl_time | 标签 | Text | 当前时间 | | +| txt_time | 文本框 | | | 显示时间 | +| lbl_mode | 标签 | Text | | 显示当前情景模式状态 | +| btn_apply | 按钮 | Text | 开启情景模式 | 控制模式开关 | +| grp_input | 组合框 | Text | 采集量 | | +| lbl_import | 标签 | Text | 串口 | | +| lbl_light | 标签 | Text | 光照度 | | +| lbl_temperation | 标签 | Text | 温度 | | +| lbl_humidity | 标签 | Text | 湿度 | | +| com_import | 下拉列表框 | | | 设置采集串口 | +| txt_light | 文本框 | | | 显示光照度 | +| txt_temperation | 文本框 | | | 显示温度 | +| txt_humidity | 文本框 | Text | | 显示湿度 | +| grp_status | 组合框 | Text | 状态栏 | | +| lbl_light | 标签 | Text | 灯光 | | +| lbl_curtain | 标签 | Text | 窗帘 | | +| lbl_boardcast | 标签 | Text | 广播 | | +| lamp | 灯泡 | | | 界面电灯开关 | +| trb_curtain | 滑动条 | | | 模拟窗帘开关 | +| lbl_curtainopen | 标签 | Text | 全开 | | +| lbl_curtainclose | 标签 | Text | 全关 | | +| pic_boardcastStatus | 图片框 | Image | Switch_off.png | 显示广播状态 | +| timer | 时间控件 | Interval | 1000 | 实现实时采集 | +| axWindowsMediaPlayer1 | Windows媒体播放器控件 | Visible | False | 用于播放mp3音乐 | + +**小贴士(axWindowsMediaPlayer 的添加方法)**:在工具箱中右键 →「选择项」→「选择工具箱项」→「COM组件」标签,勾选「Windows Media Player」→ 确定,然后从工具箱把控件拖到窗体上。 + +7. 引用 `System.IO.Ports`、`DeviceCtrlLibrary`、`DeviceCtrlLibrary.Model`、`System.Threading`。 +8. 定义变量:`bool IsOpen`、`IsLampOopen`(原文拼写)、`IsBoardcastOpen`;`ADAM4017ResModel ResModel`;`DateTime now`;四个时间点 `GetUp = DateTime.Today.AddHours(6.75)`(6:45)、`ClassBegin = AddHours(7.5)`(7:30)、`ClassEnd = AddHours(21.0)`、`Sleep = AddHours(22.0)`。 +9. Load:清空 com_import,填入串口名。 +10. btn_apply_Click:在「开启情景模式」和「停止情景模式」之间切换,同时开/关 4017、4150 和 timer。 +11. 写 `CheckMode()` 方法:按时间段 GetUp≤now (Parameter List) { Method Body }`,书中逐一解释了访问修饰符、返回类型(不返回值时用 void)、方法名(唯一,区分大小写)、参数列表和方法主体。示例: +```csharp +public int ADD(int x, int y) +{ + Int s; // 原文就是大写 Int,属于书上的笔误 + s=x+y; + return s; +} +``` + +**能力拓展** + +**边想边做**(书147): +> 通过「任务实施」的操作步骤,已经实现了智慧寝室各个模式的基本功能。在此基础上为程序添加报警灯(Alarm)和定时器(timer)控件,定时器时间间隔为 0.5s,报警灯默认情况下 Visible 为 false,请完善 timerAlarm_Tick 事件,使在熄灯模式下温度超过 32℃ 时启动界面报警闪烁功能。 + +提示1:根据下列代码的提示,完善 timer_Tick 事件代码。 +```csharp +private void timerAlarm_Tick(object sender, EventArgs e) +{ + IsAlarmOpen = ____; // IsAlarmOpen为报警灯状态变量,每次进行切换 + alarm.OnOff(____);//切换界面报警灯状态 +} +``` +提示2:根据下列代码的提示,修改完善 CheckMode 方法的代码。 +```csharp +private void CheckMode()//切换各个模式 +{ +//上述代码省略,具体参照任务实施部分代码 + else + { + lbl_mode.Text = "智慧寝室正处于熄灯模式"; + if (IsLampOopen == true) + { + IsLampOopen = false; + DeviceCtrlHelper.adam4150Helper.OnOff(1, false); + lamp.OnOff(false); + } + if (IsBoardcastOpen == true) + { + IsBoardcastOpen = false; + pic_boardcastStatus.Image = Properties.Resources.switch_off; + } + trb_curtain.Value = 10; + if (____)//温度较高开启报警 + { + alarm.Visible = true;//设置报警灯控件alarm为显示状态 + ____//开启报警灯闪烁定时器timerAlarm启动 + } + else + { + ____//设置报警灯控件alarm为不显示状态 + timerAlarm.Stop();//停止报警灯闪烁定时器timerAlarm + } + } +} +``` +这道题要用到教材类库的 **Alarm 控件**(名为 `alarm`),它有 `OnOff(bool)` 方法和 `Visible` 属性;还要第二个 Timer `timerAlarm`,Interval=500。 + +**自主探究**(书148): +> 在光照度和温湿度的基础上,实现对气压(airPressure)和空气质量(airQuality)变化的监测,打开关闭的窗户并启动排风系统。 + +### 任务3 实现智慧会议室(书148–155 / PDF 149–156) + +**任务描述**:会议室有会议、讨论、展示三种情景。采集光照度、温度、湿度、空气质量等数据,按预设值控制灯光、窗帘、广播。预约时间到达后: +- 会议模式:开灯光、窗帘、主传声器,关客传声器 +- 讨论模式:开灯光、窗帘、主传声器、客传声器 +- 展示模式:开灯光、主传声器,关窗帘和客传声器 + +窗帘用滑块条模拟,主、客传声器用控件模拟。 + +**任务实施** +1–4. 与前面相同,项目名「会议模式」,保存到 `D:\物联网编程与应用\项目7\任务3\`,复制 dll 到 `...\会议室情景模式\bin\Debug\`。书上项目名和文件夹名不一致,原文如此。 +5. 添加资源 `switch_off.png`、`switch_on.png`。 +6. 按图7-5建窗体(标题「智慧会议室情景模式」): + - 顶部「模式选择」组:当前时间「19:46:41」、标签「智慧会议室未开启情景模式」、复选框「拍照记录」;一行「[13]时[20]分[00]秒将会有场 [会议▼] [预约] [退出]」 + - 左侧「采集量」组:串口、光照度、温度、湿度、气压、空气质量,以及「摄像头IP地址」文本框 192.168.14.131 + - 右侧「状态栏」组:窗帘 TrackBar(全开/全关)、灯光开关图片 + 灯泡、「传声器」两个开关(主、客) + +表7-7 窗体界面控件属性: + +| 控件名称 | 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---|---| +| frm_control | 窗体 | Text | 智慧会议室情景模式 | | +| grp_mode | 组合框 | Text | 模式选择 | | +| lbl_time | 标签 | Text | 当前时间:00:00:00 | | +| lbl_mode | 标签 | Text | 智慧会议室未开启情景模式 | | +| chk_record | 复选框 | Text | 拍照记录 | 设置拍照开关 | +| txt_hour | 文本框 | Text | 13 | 设置预约时间的小时数 | +| txt_minute | 文本框 | Text | 20 | 设置预约时间的分钟数 | +| txt_second | 文本框 | Text | 00 | 设置预约时间的秒数 | +| lbl_hour | 标签 | Text | 时 | | +| lbl_minute | 标签 | Text | 分 | | +| lbl_second | 标签 | Text | 秒将会有场 | | +| com_mode | 下拉列表框 | Text | 会议 | 选择模式 | +| btn_reserve | 按钮 | Text | 预约 | 预约模式 | +| btn_exit | 按钮 | Text | 退出 | 退出模式 | +| grp_input | 组合框 | Text | 采集量 | | +| lbl_import | 标签 | Text | 串口 | | +| com_import | 下拉列表框 | | | 设置采集串口 | +| lbl_light | 标签 | Text | 光照度 | | +| lbl_temperation | 标签 | Text | 温度 | | +| lbl_humidity | 标签 | Text | 湿度 | | +| lbl_airPressure | 标签 | Text | 气压 | | +| lbl_airQuality | 标签 | Text | 空气质量 | | +| lbl_CO2 | 标签 | Text | CO₂浓度 | | +| txt_light | 文本框 | | | 显示光照度 | +| txt_temperation | 文本框 | | | 显示温度 | +| txt_humidity | 文本框 | Text | | 显示湿度 | +| txt_airPressure | 文本框 | Text | | 显示气压 | +| txt_airQuality | 文本框 | Text | | 显示空气质量 | +| txt_CO2 | 文本框 | Text | | 显示CO₂浓度 | +| lbl_camera | 标签 | Text | 摄像头IP地址 | | +| txt_camera | 文本框 | Text | 192.168.14.131 | 设置摄像头IP地址 | +| grp_status | 组合框 | Text | 状态栏 | | +| lbl_light | 标签 | Text | 灯光 | | +| lbl_curtain | 标签 | Text | 窗帘 | | +| lbl_boardcast | 标签 | Text | 传声器 | | +| lamp | 灯泡 | | | 模拟电灯开关 | +| trb_curtain | 滑动条 | | | 模拟窗帘开关 | +| lbl_curtainopen | 标签 | Text | 全开 | | +| lbl_curtainclose | 标签 | Text | 全关 | | +| pic_lightsStatus | 图片框 | Image | Switch_off.png | 控制并显示电灯状态 | +| pic_mainMicrophone | 图片框 | Image | Switch_off.png | 控制并显示主传声器状态 | +| pic_subMicrophone | 图片框 | Image | Switch_off.png | 控制并显示客传声器状态 | +| timer | 时间控件 | Interval | 2000 | 实现实时采集 | + +注意:图7-5 截图里没有 CO₂ 那一行。 + +7. 引用 `System.IO`、`System.IO.Ports`、`DeviceCtrlLibrary`、`DeviceCtrlLibrary.Model`。 +8. 定义变量:`bool IsLightsOpen`、`IsmainMicrophoneOpen`、`IssubMicrophoneOpen`、`IsInitialized`;`ADAM4017ResModel ResModel`;`DateTime now`;`DateTime reserve = DateTime.Now.AddDays(1)`(默认设为明天,防止出现「假预约」);`Image ReadImage = null`。 +9. Load:清空 com_import 和 com_mode,com_mode 加入「会议」「讨论」「展示」,启动 timer。如果 `BaseDirectory + "Camera/"` 不存在就创建。然后 `DeviceCtrlHelper.ipCameraHelper.ImageRead += ipCameraHelper_ImageRead;` 绑定监控事件。 +10. `void ipCameraHelper_ImageRead(ImageReadEvenArgs e) { ReadImage = e.img; }` +11. btn_reserve_Click:`reserve = DateTime.Today.AddHours(时).AddMinutes(分).AddSeconds(秒)`(逐行调用 Convert.ToInt16)。 +12–14. pic_lightsStatus / pic_mainMicrophone / pic_subMicrophone 的 Click:手动开关切换。 +15. `ChooseMode()`:`lbl_mode.Text = "智慧会议室已开启" + com_mode.Text + "模式"`,然后 switch 三种模式设置灯、窗帘(0 开 / 10 关)、主客传声器图片。 +16. timer_Tick:每次 Tick 都执行 `adam4017Helper.Open(com_import.Text)` 和 `adam4150Helper.Open(...)`,然后显示时间、采集光照/温度/湿度/气压(airPressureValue)/空气质量(airQualityValue)。当 `now >= reserve` 时:如果还没初始化,就 ChooseMode();若 chk_record 勾选,则 `ipCameraHelper.Open(txt_camera.Text, "admin", "")` 加 `ipCameraHelper.Start()`;然后 IsInitialized=true。在 `now.Second == 0 && now.Minute % 5 == 0` 时,执行 `ReadImage.Save(BaseDirectory + "Camera/" + DateTime.Now.ToString("yyyyMMdd_HH mm ss") + ".jpg")`。 +17. btn_exit_Click:停止 timer,reserve 设为明天,`chk_record.Enabled=false`,关灯,`trb_curtain.Value=0`(注释写的是「关闭窗帘」,但 0 实际是全开),关主客传声器,IsInitialized=false,关闭 4150、4017,`ipCameraHelper.Stop()`。 + +**知识提炼**:`+=` 在 C# 中有两种用法:一是复合赋值(`i+=1` 即 `i=i+1`);二是注册/订阅事件,如 `winForm.Closed += new EventHandler(child_Closed);`,变量句柄类型为 EventHandler。用 `-=` 取消订阅。事件发生时,处理程序集合中的所有方法依次调用。 + +**能力拓展** + +**边想边做**(书155): +> 通过「任务实施」的操作步骤,已经实现了智慧会议室的基本配置。现在修改程序:通过增加一个文本框控件 txt_duration 获取预约时长(单位 min),实现预约时间内设备的开启,预约时间结束后则关闭设备。请根据下列提示,实现上述功能。 +> 提示:根据以下提示,完善 timer_Tick 事件 + +```csharp +private void timer_Tick(object sender, EventArgs e) +{ +//以上代码忽略,详见任务实施timer_Tick事件代码 + if (now >= reserve && ____)//是否在预约时间段内,txt_duration的Text属性接收预约时长 + { + if (!IsInitialized) + { + ChooseMode();//进入选择的模式 + //此部分代码忽略,详见任务实施timer_Tick事件代码 + } + } + else//预约时间外关闭设备 + { + ____//停止timer定时器 + ____//修改reserve变量为明天,防止初始值小于当前时间的假预约情况 + chk_record.Enabled = false; + pic_lightsStatus.Image = Properties.Resources.switch_off;//转换灯光图片 + lamp.OnOff(false);//关闭界面灯光 + DeviceCtrlHelper.adam4150Helper.OnOff(1, false);//关闭1通道灯 + trb_curtain.Value = 0;//关闭窗帘 + pic_mainMicrophone.Image = Properties.Resources.switch_off;//关闭主传声器 + pic_subMicrophone.Image = Properties.Resources.switch_off;//关闭客传声器 + IsInitialized = false;//初始化状态 + DeviceCtrlHelper.adam4150Helper.Close(); + DeviceCtrlHelper.adam4017Helper.Close();//关闭串口 + DeviceCtrlHelper.ipCameraHelper.Stop();//关闭视频监控 + } +} +``` +(原文印刷错误:`lam•p.OnOff(false)` 应为 `lamp.OnOff(false)`。另外这道题的逻辑有缺陷:预约时间到达之前,timer 也会走进 else 分支把 timer 停掉。) + +**自主探究**(书155): +> 在「边想边做」的基础上,为程序增加下列功能:当开启情景模式时,若时间早于 6 时或晚于 18 时,则窗帘自动关闭,打开照明灯。 + +### 项目7 项目评价(书156) + +| 任务 | 评价指标 | 具体要求 | +|---|---|---| +| 任务1 | 窗体界面 | 窗体控件正确,布局美观 | +| 任务1 | 数据显示 | 能实时显示环境数据 | +| 任务1 | 自动控制 | 能根据环境数据的变化实时控制智能设备 | +| 任务2 | 窗体界面 | 窗体控件正确,布局美观 | +| 任务2 | 数据显示 | 能实时显示环境数据 | +| 任务2 | 自动控制 | 能根据时间段的变化判断寝室情景实时控制智能设备 | +| 任务3 | 窗体界面 | 窗体控件正确,布局美观 | +| 任务3 | 数据显示 | 能实时显示环境数据 | +| 任务3 | 实现预约时段后开启自动控制功能 | 能根据预约时间控制智能设备 | +| 任务3 | 监控抓取 | 能定时实现监控数据的抓取 | +| 职业素养 | 代码书写规范 | 应该遵从结构化的要求,采用缩进的格式 | +| 职业素养 | 注释书写规范 | 做到清晰,准确地描述内容。对于程序中复杂的部分必须有注释加以说明。注释量要适中,避免过多或过少导致阅读困难 | +| 职业素养 | 变量命名规范 | 选择有意义的英文进行命名,使变量名能清楚地表达其含义。尽量少使用人名、地名和汉语拼音。应使用那些约定俗成的缩写。多个单词组成的变量名,每个单词的首字母应该大写 | + +后面是「个人学习总结」(项目学习情况 / 心得与反思)。表格没有分值。 + +### 项目7 书与参考代码的差异 / 模拟器要点 +- **任务1 pic_lightsStatus_Click 的判断条件,书上写反了**:书上是 `if (!IsLampOpen) { 关灯 } else { 开灯 }`,参考代码 `项目7/任务一/任务1/教室情景模式/教室情景模式/教室情景模式.cs` 是 `if (IsLampOpen) { 关灯 }`。按书上写,点了灯不会亮。 +- 任务1参考工程里的窗体文件叫 `教室情景模式.cs`,不是 Form1.cs。参考 Designer 中 `trb_airconditione` 的 Minimum=18、Maximum=32、Value=18,书上表格没写这几个值,起始模板要加上。timer.Interval 两边都是 2000。 +- 任务2:`PlayMp3` 在后台线程里直接改 `pic_boardcastStatus.Image` 并调用 WMP 控件。书上和参考代码都没有设置 `CheckForIllegalCrossThreadCalls=false`,也没有用 Invoke(Release 版本能跑,调试时会报跨线程异常)。模拟器要么允许跨线程访问 UI,要么自动封送到 UI 线程。参考 Designer 里多了一个 `alarm1`(`WinFormsDeviceControlLibrary.Alarm`,尺寸 7×6),可能是给「边想边做」预留的。 +- **模拟器需要支持的控件和 API**(从代码本身看不太出来): + - `WinFormsDeviceControlLibrary.Lamp`:`OnOff(bool)` + - `WinFormsDeviceControlLibrary.Alarm`:`OnOff(bool)`、Visible(任务2拓展用) + - **AxWindowsMediaPlayer(COM 控件)**:`URL` 属性、`Ctlcontrols.play()`,要能播放资源里的 `ring.mp3`,可以简化为提示音或「广播中」动画 + - `Properties.Resources.switch_on/switch_off`(png 图片资源) + - `System.Threading.Thread`、`Thread.Sleep` + - `ADAM4017ResModel` 的字段:`lightValue`、`temperatureValue`、`humidityValue`、`airPressureValue`、`airQualityValue`,都是 string。参考代码注释里还提到 `CO2Value`,书上表格有 txt_CO2,但代码里从没赋值 + - `adam4150Helper.OnOff(通道, bool)` + - `ipCameraHelper.Open(ip, uid, pwd)`、`.Start()`、`.Stop()`、`ImageRead` 事件(参数类型 `ImageReadEvenArgs`,字段 `.img`,类型为 Image)、`Image.Save(path)`;以及 `Directory.Exists`、`Directory.CreateDirectory`、`AppDomain.CurrentDomain.BaseDirectory` + - 任务2 依赖真实时钟(6:45 / 7:30 / 21:00 / 22:00),任务3 依赖预约时间和「每5分钟整点拍照」。模拟器最好提供**可调的模拟时钟**,否则学生很难测试各种模式 +- 任务3:书上和参考代码的 Load 都**没有往 com_import 填串口名**(注释里写了但代码没做),而 timer 从 Load 起就在跑,并且每个 Tick 都 Open 一次串口。参考代码 timer.Interval=1000,书上表格是 2000。两边都没有给 `txt_CO2` 赋值。ChooseMode 只改了图片,没有同步 IsLightsOpen 等状态变量,所以模式切换后手动点开关时第一次点击状态会对不上(书上和参考代码都这样)。 + +--- + +## 项目8 智慧校园管理平台开发(书157–186) + +**项目概述**:综合前面7个项目,做一个智慧校园管理平台,对校园物联网设备和信息化做监控和管理,方便管理部门实时监控、处理突发事件,界面要友好。 + +**学习目标**: +- 理解平台各功能模块的用途及模块间的相互关系 +- 掌握各界面设计以及图标绘制的方法 +- 能根据实际应用需求完成物联网应用界面设计 +- 能对传感器采集的数据做简单分析 +- 能识别系统开发中的常见错误与系统故障,掌握基本排查方法 +- 养成严谨细致的工作作风和认真负责的工作态度 +- 培养坚韧不拔、勇于面对困难的品质 + +本项目各任务**都没有「知识提炼」和「能力拓展」**,只有任务描述和任务实施,最后是项目评价。 + +### 任务1 制作管理平台的主界面(书157–159 / PDF 158–160) + +**任务描述**:主界面提供所有功能的入口,要求简洁、易学、好用。包含两个入口:校园环境、校园安防。 + +**任务实施** +1. 新建 WinForms 项目「智慧校园」,保存到 `D:\物联网编程与应用\项目8\任务1\`。 +2. 把「综合实训」文件夹下的 Resources 文件夹复制到 `...\智慧校园\bin\Debug`,并在解决方案中导入。参考工程对应的是 `项目8/Resources`。 +3. 设置窗体属性。表8-1 窗体属性: + +| 属性名 | 属性值 | +|---|---| +| Name | Main | +| FormBorderStyle | none | +| StartPosition | CenterScreen | + +4. 加两个 Panel: + - panel1:BackColor = 53,147,237,Dock = Top;里面放一个 PictureBox,SizeMode = StretchImage,Image = `Resources/ico_title.png`(「智慧校园」标题图) + - panel2:Dock = Fill,BackgroundImageLayout = Stretch,BackgroundImage = `Resources/bg_home.png`(校园建筑照片) + + 效果见图8-1。 +5. 在 panel2 上放 3 个 PictureBox(效果见图8-2,三个方形图标:校园环境/树、校园安防/盾牌、退出/箭头): + +表8-2 校园环境图片框属性: + +| 属性名 | 属性值 | +|---|---| +| Name | pic_environment | +| BackColor | Transparent | +| Image | 智慧校园.Properties.Resources.icon_02 | + +表8-3 校园安防图片框属性: + +| 属性名 | 属性值 | +|---|---| +| Name | pic_security | +| BackColor | Transparent | +| Image | 智慧校园.Properties.Resources.icon_03 | + +表8-4 退出图片框属性: + +| 属性名 | 属性值 | +|---|---| +| Name | pic_exit | +| BackColor | Transparent | +| Image | 智慧校园.Properties.Resources.icon_04 | + +6. pic_exit 的 Click:`this.Close();` + +### 任务2 制作「校园环境」界面(书159–164 / PDF 160–165) + +**任务描述**:实时采集温度、湿度等气象信息并推送到 LED 屏;监测火焰、烟雾等安防信息,一旦有险情第一时间推送到 LED 预警;根据光照度自动开关路灯;对历史气象数据做分析比较。 + +**任务实施**(本任务只做界面) +1. 新建窗体 Environment,并调整大小。表8-5 Environment 窗体属性: + +| 属性名 | 属性值 | +|---|---| +| Name | Environment | +| FormBorderStyle | none | +| StartPosition | CenterScreen | + +2. 布局 Panel: + - panel1:BackColor = 100,116,133,Dock = Top,内放 PictureBox(StretchImage,`ico_title.png`) + - panel2:Dock = Fill,panel2 里再放 panel3(Dock = Top) + + 效果见图8-3。 +3. panel3:BackColor = 229,229,229,上面放标签 `lb_return`,Text =「<返回主窗体」(图8-4)。 +4. panel3 下方放 `panel_bg`:Dock = Fill,BackgroundImageLayout = Stretch,BackgroundImage = `day_02.png`(带路灯的建筑图,图8-5)。 +5. 在 panel_bg 上放 `panel5`(Dock = Bottom),控件见表8-6,界面见图8-6:一行「环境数据 火焰[ ] 烟雾[ ] 光照度[ ] 温度[ ] 湿度[ ] [数据分析] [推送天气信息]」。 + +表8-6 窗体控件: + +| 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---| +| 文本框 | Name | txt_fire | 显示火焰值文本框 | +| 文本框 | Name | txt_smoke | 显示烟雾值文本框 | +| 文本框 | Name | txt_light | 显示光照度值文本框 | +| 文本框 | Name | txt_temperature | 显示温度值文本框 | +| 文本框 | Name | txt_humidity | 显示湿度值文本框 | +| 按钮 | Name | 数据分析 | 数据分析按钮 | +| 按钮 | Name | 推送天气信息 | 推送天气信息按钮 | +| 其余均为标签 | | | | + +(书上把按钮的「Name」写成了中文文字。参考代码中的实际名字是 `btn_dataAnalyse`(Text「数据分析」)和 `btn_ledEnvironment1`(Text「推送天气信息」)。) + +6. 在 panel_bg 上放 `panel_form`:BackColor = Transparent,BackgroundImage = `bg_form2.png`,Dock = Right(图8-7)。参考资源里的文件名是 `bg_from2.png`,书上写成了 bg_form2。 +7. 在 panel_form 上做两个模块(图8-8): + - 「路灯控制」组:复选框「自动控制」、「设定开灯光照度值 [ ] lx」、「灯光控制 [开关]」 + - 「LED推送」组:「LED信息」多行文本框、复选框「自动推送火情」、按钮「推送LED信息」「推送天气信息」 + +表8-7 panel_form 里面主要控件的属性设置: + +| 控件类型 | 属性名 | 属性值 | 控件功能 | +|---|---|---|---| +| 复选框 | Name | ch_autoLamp | 自动控制复选框 | +| 复选框 | Text | 自动控制 | | +| 文本框 | Name | txt_autoLight | 设置控制开灯的光照度值 | +| 图片框 | Name | pic_lampOpen | 灯光控制图片框 | +| 图片框 | BackColor | Transparent | 灯光控制图片框 | +| 图片框 | BackgroundImage | 智慧校园.Properties.Resources.switch_off | 灯光控制图片框 | +| 复选框 | Name | ch_autoLedFire | 自动推送火情复选框 | +| 复选框 | Text | 自动推送火情 | | +| 按钮 | Name | 推送天气信息 | 推送天气信息按钮 | +| 按钮 | Name | 推送LED信息 | 推送LED信息按钮 | +| 文本框 | Name | txt_ledMsg | LED信息文本框 | +| 其余控件 | BackColor | Transparent | | + +(参考代码中按钮名为 `btn_ledEnvironment2`(推送天气信息)和 `btn_ledSendMsg`(推送LED信息)。) + +8. 在 panel_bg 上加 **Chart 图表控件 chart1**: + - Legends 加两项:`MaxLegend`、`AVGLegend` + - Series 设置(图8-9、图8-10「Series 集合编辑器」): + +| Series | XValueMember | YValueMembers | ChartArea | ChartType | IsVisibleInLegend | Legend | LegendText | +|---|---|---|---|---|---|---|---| +| MaxSeries | _Time | MaxTemperature | ChartArea1 | Line | True | MaxLegend | 最大值 | +| AVGSeries | _Time | AVGTemperature | ChartArea1 | Line | True | AVGLegend | 平均值 | + + 另外 XValueType=Auto,YValuesPerPoint=1,YValueType=Auto。完整效果见图8-11:左下方是折线图(Y 轴 0–100,X 轴 1–8,图例「平均值」「最大值」),右侧是控制面板。 + +### 任务3 实现「校园环境」数据采集及数据分析功能(书164–174 / PDF 165–175) + +**任务描述**:实现数据库的查询分析,并把结果显示到界面上。 + +**任务实施**(代码在参考工程 `项目8/智慧校园/智慧校园/` 里都有,这里只列步骤) +1. 导入数据采集相关 dll;新建 `AppSet` 类和 `SQLHelpers` 文件夹,文件夹里放 `SQLBLL`、`SQLHelper` 两个类。 +2. AppSet.cs 内容:串口 `ADAM4017_COM="COM2"`、`ADAM4150_COM="COM3"`、`LED_COM="COM5"`、`ZigBee_COM="COM4"`;摄像机 `Camera_Ip="192.168.1.100"`、`Camera_Uid="admin"`、`Camera_Pwd=""`;`SqlConn="Data Source=.;Initial Catalog=WisdomCampus;User ID=sa;Password=123"`。 + **小贴士**:运行前先配置好数据库。在「综合实训」文件夹找到数据库文件 WisdomCampus,附加到数据库中;设置用 **sa 用户登录、密码 123**;导入程序运行需要的库文件;按串口设置连接好对应串口。 +3–4. SQLHelper(引用 System.Data.SqlClient、System.Drawing、System.IO): + - `strConn = AppSet.SqlConn` + - `SaveData(sql, DataTable)`:SqlDataAdapter + SqlCommandBuilder + Update + - `getData(sql, params SqlParameter[])` 返回 DataSet + - `getData(SqlCommand)` + - `Exec(sql, params SqlParameter[])`:ExecuteNonQuery,异常时返回 −1 +5–6. SQLBLL: + - `SecurityUpData(Image)`:用 MemoryStream 把图片存成 Jpeg 字节,执行 `INSERT INTO SecurityData (EvenTime,EvenImage) VALUES (@time, @img)` + - `GetSecurityData(DateTime startDate, DateTime endDate)` 返回 `Dictionary`:执行 `SELECT * FROM SecurityData WHERE EvenTime between @time_start and @time_end`,end 拼成「年/月/日 23:59:59」,然后 `Image.FromStream(new MemoryStream((byte[])...))`。书上这段有笔误 `eturn ImgDictionary;` + - `EnvironmentUpData(bool fire, bool smoke, string light, string temperature, string humidity)`:执行 `INSERT INTO EnvironmentData (Fire, Smoke, Temperature, Humidity, Light, _Time) VALUES (@fire,@smoke,@temperature,@humidity,@light,@time)` + - `GetEnvironmentGraphData()`:执行 `SELECT TOP 7 CONVERT(varchar(10),_Time,120) AS _Time, ROUND(Max(Temperature),2) AS MaxTemperature, ROUND(AVG(Temperature),2) AS AVGTemperature FROM EnvironmentData GROUP BY CONVERT(varchar(10),_Time,120) ORDER BY CONVERT(varchar(10),_Time,120) DESC` +7. 在 SQLBLL 文件中加 `class SQLRes { string Msg; bool Result; }`。 +8–9. 主窗体引用 DeviceCtrlLibrary。Main_Load 中依次执行 `adam4017Helper.Open(AppSet.ADAM4017_COM)`、`adam4150Helper.Open(AppSet.ADAM4150_COM)`、`zigBeeHelper.Open(AppSet.ZigBee_COM)`、`ledHelper.OpenLedCom(AppSet.LED_COM)`,全部包在 try/catch 里。 +10. pic_environment_Click:`new Environment().Show();` +11–12. Environment 引用 DeviceCtrlLibrary、DeviceCtrlLibrary.Model、智慧校园.SQLHelpers、System.Threading;字段有 `Thread thread`、`SynchronizationContext _main`、`ADAM4017ResModel ResModel_4017`、`ADAM4150ResModel ResModel_4150`、`double environmentLight, setLight`、`bool IsAuto_Lamp, IsAuto_FireMsg, IsLampOpen`。 +13. Environment_Load:`chart1.Visible=false`;`thread = new Thread(new ThreadStart(Tick))`;`_main = WindowsFormsSynchronizationContext.Current`;`thread.Start()`。 +14. `Tick()` 是一个 while(true) 循环: + - 采集 4017,`Thread.Sleep(500)`,再采集 4150 + - 通过 `_main.Send(_ => {...}, null)` 回到 UI 线程更新显示:`txt_fire = fireValue ? "发生火情" : "正常"`,`txt_smoke = smokeValue ? "发生烟情" : "正常"`,以及光照、温度、湿度 + - 自动路灯:`double.TryParse(lightValue, out environmentLight)`;当 IsAuto_Lamp 为真且 environmentLight < setLight 时 `LampOnOff(1,true)`,否则 `LampOnOff(1,false)` + - 自动推送火情:火且烟时推送「发生严重火灾」,只有火时推送「发现火情」,只有烟时推送「发现烟情」,都用 `ledHelper.SendLedString` + - 调用 `SQLBLL.EnvironmentUpData(...)` 上传数据,然后 `Thread.Sleep(5000)` +15. `LampOnOff(int RelayNum, bool IsOpen)`:调用 `zigBeeHelper.OnOff(RelayNum, IsOpen)`,再用 `_main.Send` 切换背景:开灯时 `panel_bg.BackgroundImage = day_01`、`pic_lampOpen.BackgroundImage = switch_on`,关灯时换成 `day_02` 和 `switch_off`。 +16. Environment_FormClosed:`thread.Suspend(); thread = null;` +17. btn_dataAnalyse_Click:按钮文字为「数据分析」时,`chart1.DataSource = SQLBLL.GetEnvironmentGraphData(); chart1.DataBind(); chart1.Visible = true;`,并把按钮改为「关闭图表」;为「关闭图表」时反过来。 +18. 两个「推送天气信息」按钮:`SendLedString("当前温度:" + temperatureValue + "℃ 湿度:" + humidityValue + "%")`。 +19. pic_lampOpen_Click:根据 IsLampOpen 调用 LampOnOff 切换,然后 `IsLampOpen = !IsLampOpen`。 +20. 「推送LED信息」按钮:`SendLedString(txt_ledMsg.Text)`。 +21. txt_autoLight_TextChanged:`double.TryParse(txt_autoLight.Text, out setLight)`。 +22. ch_autoLamp_CheckedChanged:`IsAuto_Lamp = ch_autoLamp.Checked;` +23. 书上写「在 ch_autoLamp 的 CheckedChanged 事件中输入」`IsAuto_FireMsg = ch_autoLedFire.Checked;`。这里书上写错了,应该是 **ch_autoLedFire** 的 CheckedChanged。 + +### 任务4 实现「校园安防」功能模块(书174–180 / PDF 175–181) + +**任务描述**:实时监控,有人入侵时摄像机抓拍,把非法入侵人员拍照记录下来。 + +**任务实施** +1. 添加窗体 `Security.cs` 和 `SecurityCameraData.cs`,Security 的顶部按任务2的方法布局(图8-12:标题栏 +「<返回主窗体」)。 +2. 加 `panel_bg`:Dock = Fill,BackColor = White。 +3. panel_bg 上加 `panel_bg_video`:BackColor = DimGray。 +4. panel_bg_video 上加 PictureBox `pic_video`:BackgroundImage = `icon_monitor.png`,BackgroundImageLayout = Center。 +5. panel_bg_video 上再加 Label「入侵:」和 Label `lb_infrared`(Text = 「N/A」),效果见图8-13。 +6. panel_bg 上加 `panel_video_ctrl`:BackgroundImage = `panel_video_ctrl.png`(圆形方向盘底图),Layout = Stretch。 +7. panel_video_ctrl 上加 4 个 PictureBox:`pic_up`、`pic_down`、`pic_left`、`pic_right`,BackgroundImage 分别为 `btn_arrow_up_normal.png`、`btn_arrow_down_normal.png`、`btn_arrow_left_normal.png`、`btn_arrow_right_normal.png`,Layout = Stretch(图8-14)。 +8. panel_bg 上加按钮 `btn_camera`(Text「拍照」)、`btn_history`(Text「查看历史图像」),完整效果见图8-15。 +9. 引用 System.IO、System.Threading、DeviceCtrlLibrary、智慧校园.SQLHelpers。 +10. 字段:`bool IsInfrared`;`string ImgDir = AppDomain.CurrentDomain.BaseDirectory + "Camera\\"`;`Thread thread`;`SynchronizationContext _main`;`Image img = null`。 +11. `tick()` 是 while(true) 循环:执行 `IsInfrared = adam4150Helper.SetData().infraredValue`;通过 `_main.Send` 设置 `lb_infrared.Text = IsInfrared ? "发现入侵" : "正常"`;若有入侵且 img 不为 null,执行 `SQLBLL.SecurityUpData(img)`;然后 `Thread.Sleep(1000)`。 +12. `ipCameraHelper_ImageRead(ImageReadEvenArgs e)`:`img = e.img; pic_video.Image = e.img;`(在相机回调线程里直接设置 UI)。 +13. Security_Load:`ipCameraHelper.Open(AppSet.Camera_Ip, Camera_Uid, Camera_Pwd)`,`ImageRead += ...`,`Start()`;启动线程;ImgDir 不存在时创建。 +14. Security_FormClosed:`ipCameraHelper.Stop(); ipCameraHelper.Close(); thread.Suspend(); thread = null;` +15. 「拍照」按钮:`pic_video.Image.Save(ImgDir + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".jpg"); SQLBLL.SecurityUpData(pic_video.Image);` +16. 「查看历史图片」按钮:`new SecurityCameraData().ShowDialog();` +17–20. 四个方向图片的 MouseDown / MouseUp:MouseDown 时 BackgroundImage 换成 `btn_arrow_xx_press` 并调用 `ipCameraHelper.PanUp()`(或 PanDown / PanLeft / PanRight);MouseUp 时换回 `_normal`,并**再调用一次同一个 Pan 方法**。书上和参考代码都这样写,可能这个 API 是开始/停止切换式的。 +21. 主窗体 pic_security 的 Click:`new Security().Show();` +22. lb_return 的 Click:`this.Close();` + +### 任务5 实现「校园安防」模块中显示历史图片的功能(书181–185 / PDF 182–186) + +**任务描述**:从数据库中查出非法入侵时拍下的照片,按时间快速定位目标人物。 + +**任务实施** +1. SecurityCameraData 窗体:StartPosition = CenterScreen,FormBorderStyle = none。 +2. 加 `Panel1`:BackColor = 100,116,133,Dock = Top。上面放 label1(Text「历史报警记录」,ForeColor = White)和 PictureBox `pic_close`(BackgroundImage = `windowstate_closed_hover.png`,Layout = Center),效果见图8-16。 +3. Panel1 下方加 `panel_toolBar`:BackColor = 229,229,229。上面放两个标签(「开始时间」「结束时间」)、`dateTimePicker1`、`dateTimePicker2`,以及按钮 `btn_get`(Text「查询」),效果见图8-17。 +4. 加 PictureBox `pic_camera`:BackColor = 64,64,64。上面放标签 `lb_noData`(Text「NO DATA」,ForeColor = Red,BackColor = 64,64,64,字号 48)和标签 `lb_time`(Text「TIME」,ForeColor = Red,BackColor = 64,64,64),效果见图8-18。 +5. 在 pic_camera 上加两个 PictureBox `pic_up`、`pic_down`:Image 分别为 `arrow_left_normal.png`、`arrow_right_normal.png`,SizeMode = CenterImage(图8-19:左右翻页箭头)。 +6. 引用 DeviceCtrlLibrary、智慧校园.SQLHelpers。 +7. 字段:`Dictionary ImgDictionary`、`int index = -1`、`Image img`、`List Keys`。 +8. `ImageChanged(char UpOrDown = '+')`:按 '+' 或 '-' 移动 index 并循环取模,然后 `TryGetValue(Keys[index], out img)`,`pic_camera.Image = img; lb_time.Text = Keys[index];`。 +9. `GetData()`:用 dateTimePicker1/2 的 Value 调用 `SQLBLL.GetSecurityData(...)`,index 置为 -1;没有数据时显示 lb_noData 并 return,否则隐藏 lb_noData,把所有 key 加进 Keys,再调用 ImageChanged()。 + 注意:Keys 每次查询都没有 Clear,重复查询会越积越多,书上原样如此。 +10. Load:`pic_up.Parent = pic_camera; pic_down.Parent = pic_camera; pic_up.Location = new Point(20,0); pic_down.Location = new Point(560,0); GetData();` +11. btn_get 的 Click:`GetData();` +12. pic_up 的 Click:`ImageChanged('-');` +13. pic_down 的 Click:`ImageChanged();` +14. pic_close 的 Click:`this.Close();` + +### 项目8 项目评价(书185–186) + +| 任务 | 评价指标 | 具体要求 | +|---|---|---| +| 任务1 | 窗体界面 | 界面布局合理,美观大方,符合使用者的习惯 | +| 任务1 | 功能实现 | 实现退出功能 | +| 任务2 | 窗体界面 | 界面布局合理,美观大方,符合使用者的习惯 | +| 任务2 | 功能实现 | 路灯能手动、自动控制;实现多个LED信息推送功能;图表能正确显示数据 | +| 任务3 | 窗体界面 | 界面布局合理,美观大方,符合使用者的习惯 | +| 任务3 | 功能实现 | 人体入侵有文字提示;摄像机能正常打开并可调整方向;摄像机能拍照并保存图片 | +| 任务4 | 窗体界面 | 界面布局合理,美观大方,符合使用者的习惯 | +| 任务4 | 功能实现 | 能根据日期查找历史照片并显示;照片能根据图片按钮切换 | +| 任务5 | 窗体界面 | 界面布局合理,美观大方,符合使用者的习惯 | +| 任务5 | 功能实现 | 能正确查找时间段内的历史图片并显示 | +| 职业素养 | 界面设计规范 | 总体原则以用户为中心,有良好的视觉特征,为用户提供直接而直观的入口,界面美观简洁,风格统一 | +| 职业素养 | 代码书写规范 | 应该遵从结构化的要求,采用缩进的格式 | +| 职业素养 | 注释书写规范 | 做到清晰、准确地描述内容。对于程序中复杂的部分必须有注释加以说明。注释量要适中,避免过多或过少导致阅读困难 | +| 职业素养 | 变量命名规范 | 选择有意义的英文进行命名,使变量名能清楚地表达其含义。尽量少使用人名、地名和汉语拼音。应使用那些约定俗成的缩写。多个单词组成的变量名,每个单词的首字母应该大写 | +| 职业素养 | 界面常用控件命名约定 | 窗体控件命名以控件名称缩写前3个字母开头,以具有代表含义的词来命名,其形式为:控件缩写(前3个字母)+命名(具有代表含义的单词) | +| 职业素养 | 电工安全操作规范 | 所有路灯安装操作必须断电,电线接头必须用绝缘包布包扎好,规范使用各类工具 | + +注意:这张评价表的任务编号和正文错开了一位。表中「任务2」包含了正文任务2(界面)和任务3(路灯、LED、图表)的功能;表中「任务3」对应正文的**任务4 安防**;表中「任务4」「任务5」都在说正文的**任务5 历史图片**。表格没有分值,后面是「个人学习总结」。 + +### 项目8 书与参考代码的差异 / 模拟器要点 +- **参考代码不完整**(`项目8/智慧校园/智慧校园/Environment.cs`): + - `btn_ledSendMsg_Click`(书步骤20:推送 txt_ledMsg)是**空方法** + - `ch_autoLamp_CheckedChanged`(书步骤22)是**空方法** + - `ch_autoLedFire` 没有绑定任何 CheckedChanged 处理程序(书步骤23) + + 所以参考程序里「自动控制」和「自动推送火情」两个复选框不起作用。做平台时以书为准补全。 +- 书上步骤23的控件名写错了(ch_autoLamp 应为 ch_autoLedFire);表8-6、8-7 把按钮的 Name 写成了中文。实际名字是:btn_dataAnalyse、btn_ledEnvironment1、btn_ledEnvironment2、btn_ledSendMsg。 +- 资源文件名:书上写 `bg_form2.png`,参考资源中是 `bg_from2.png`(另有 `bg_from1.png`)。其他资源都在 `项目8/Resources`:`ico_title`、`bg_home`、`icon_02/03/04`、`day_01/02`、`switch_on/off`、`icon_monitor`、`panel_video_ctrl`、`btn_arrow_*_normal/press`、`arrow_left/right_normal`、`windowstate_closed_hover`、`pic_big`。 +- 数据库:`项目8/DataBase/WisdomCampus.mdf`。表 `EnvironmentData` 的字段为 Fire (bit)、Smoke (bit)、Temperature、Humidity、Light、_Time;表 `SecurityData` 的字段为 EvenTime、EvenImage (varbinary/image)。模拟器的 SQL 层需要支持: + - **T-SQL 函数** `CONVERT(varchar(10), _Time, 120)`、`ROUND`、`MAX`、`AVG`、`GROUP BY`、`TOP 7` + - 参数化查询 `@param`(`SqlParameter`、`params SqlParameter[]`) + - 二进制图片列(byte[] ↔ Image,经 MemoryStream,`Image.Save(ms, ImageFormat.Jpeg)`、`Image.FromStream`) + - `SqlCommandBuilder` + - 若后端是 SQLite,CONVERT 和 TOP 需要做方言转换 +- **新出现的设备 API**(之前的项目里没有): + - `DeviceCtrlHelper.zigBeeHelper.Open(com)`、`.OnOff(relayNum, bool)`(路灯继电器) + - `DeviceCtrlHelper.ledHelper.OpenLedCom(com)`、`.SendLedString(string)`(LED 屏,模拟器应显示一块 LED 屏) + - `adam4150Helper.SetData()` 返回 `ADAM4150ResModel`,字段 `fireValue`、`smokeValue`、`infraredValue` 都是 bool + - `ipCameraHelper.Open(ip, uid, pwd)` / `Start()` / `Stop()` / `Close()` / `PanUp/PanDown/PanLeft/PanRight()` / `ImageRead` 事件 + - 设备串口固定在 AppSet:4017=COM2、4150=COM3、ZigBee=COM4、LED=COM5,摄像机 IP 为 192.168.1.100,用户 admin,密码为空 +- **新出现的 WinForms 特性**: + - 多窗体:`new Environment().Show()`、`new SecurityCameraData().ShowDialog()`、`this.Close()` + - 无边框窗体 FormBorderStyle=None,自己做标题栏 + - Panel 的 Dock(Top/Fill/Bottom/Right)嵌套布局 + - BackgroundImage + BackgroundImageLayout(Stretch/Center) + - BackColor=Transparent、RGB 颜色值、`Control.Parent` 动态改父容器、`Location = new Point(...)` + - PictureBox 的 MouseDown/MouseUp 事件,SizeMode 为 StretchImage/CenterImage + - **System.Windows.Forms.DataVisualization.Charting.Chart**:Legends、Series、XValueMember/YValueMembers、DataSource + DataBind、ChartType=Line + - 后台 `Thread` + `SynchronizationContext`(`WindowsFormsSynchronizationContext.Current`、`_main.Send(_ => {...}, null)`) + - `thread.Suspend()`(已过时的 API,书上和参考代码都在用,模拟器要么支持,要么提示改用标志位退出) + - `Dictionary`、`List` + - `DateTimePicker.Value` + - `double.TryParse(..., out ...)` + - 文本框的 TextChanged 和复选框的 CheckedChanged 事件 +- 任务3 的 Tick 循环:每 5.5 秒采集一次,每次都写入数据库(EnvironmentUpData);图表取最近 7 天的每日最高温和平均温。模拟器要能生成足够的历史数据,图表才有内容。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..f4bb3f3 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "iot-sim", + "private": true, + "workspaces": ["shared", "server", "web"], + "scripts": { + "dev:server": "bun run --cwd server dev", + "dev:web": "bun run --cwd web dev", + "build:runtime": "dotnet build runtime/Runtime.slnx -c Debug" + } +} diff --git a/runtime/Directory.Build.props b/runtime/Directory.Build.props new file mode 100644 index 0000000..8c8b537 --- /dev/null +++ b/runtime/Directory.Build.props @@ -0,0 +1,12 @@ + + + net10.0 + latest + enable + + disable + + false + false + + diff --git a/runtime/Runtime.slnx b/runtime/Runtime.slnx new file mode 100644 index 0000000..3445165 --- /dev/null +++ b/runtime/Runtime.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs new file mode 100644 index 0000000..3cbb999 --- /dev/null +++ b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs @@ -0,0 +1,151 @@ +using System; +using System.Linq; +using DeviceCtrlLibrary.Model; +using Sim.Host; + +namespace DeviceCtrlLibrary; + +/// +/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。 +/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数。 +/// +public class DeviceCtrlHelper +{ + // 教材 dll 里这些是公开静态字段(不是属性),这里保持一致 + public static SerialPortHelper serialPortHelper = new(); + public static LEDHelper ledHelper = new(); + public static ADAM4017Helper adam4017Helper = new(); + public static ADAM4150Helper adam4150Helper = new(); + public static ZigBeeHelper zigBeeHelper = new(); +} + +public class SerialPortHelper +{ + private string? _portName; + private int _baudRate; + + public void Open(string portName, int BaudRate) + { + _portName = portName; + _baudRate = BaudRate; + } + + /// 返回一串十六进制字节(大写、空格分隔、末尾带空格),未打开时返回 "Error" + public string GetStringData() + { + if (string.IsNullOrWhiteSpace(_portName) || _baudRate == 0) return "Error"; + var bytes = new byte[27]; + Random.Shared.NextBytes(bytes); + return string.Concat(bytes.Select(b => $"{b:X2} ")); + } + + public void Close() => _portName = null; +} + +public class LEDHelper +{ + private const string SendFailed = "Error:发送失败"; + private string? _portName; + + public void OpenLedCom(string portName) => _portName = portName; + + /// 成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败" + public string SendLedString(string sendString) + { + if (string.IsNullOrWhiteSpace(_portName)) return SendFailed; + SimHost.SendDevice("led", "text", sendString); + return sendString; + } + + /// 教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留空方法让书上代码能编译 + public void Close() => _portName = null; +} + +public class ADAM4017Helper +{ + private string? _portName; + private int[] _channels = { 0, 1, 2, 3, 4, 5, 6 }; + + public void Open(string PortName, int Temperature_Port = 0, int Humidity_Port = 1, int Light_Port = 2, + int AirPressure_Port = 3, int AirQuality_Port = 4, int CO2_Port = 5, int Wind_Port = 6) + { + _portName = PortName; + _channels = new[] { Temperature_Port, Humidity_Port, Light_Port, AirPressure_Port, AirQuality_Port, CO2_Port, Wind_Port }; + } + + /// + /// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错); + /// 串口未打开时所有属性为 null,与教材仿真版一致。 + /// + public ADAM4017ResModel SetData() + { + var model = new ADAM4017ResModel(); + if (string.IsNullOrWhiteSpace(_portName)) return model; + model.temperatureValue = Read(0); + model.humidityValue = Read(1); + model.lightValue = Read(2); + model.airPressureValue = Read(3); + model.airQualityValue = Read(4); + model.CO2Value = Read(5); + model.windValue = Read(6); + return model; + } + + private string Read(int quantity) => + TrainingBoard.AnalogInputs.TryGetValue(_channels[quantity], out var value) ? value.ToString() : "0"; + + public void Close() => _portName = null; +} + +public class ADAM4150Helper +{ + private string? _portName; + private int[] _channels = { 0, 1, 2, 3 }; + + public void Open(string PortName, int BodyInfrared_Port = 0, int Fire_Port = 1, int Smoke_Port = 2, int Infrared_Port = 3) + { + _portName = PortName; + _channels = new[] { BodyInfrared_Port, Fire_Port, Smoke_Port, Infrared_Port }; + } + + /// 读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false + public ADAM4150ResModel SetData() + { + var model = new ADAM4150ResModel(); + if (string.IsNullOrWhiteSpace(_portName)) return model; + model.bodyInfraredValue = Read(0); + model.fireValue = Read(1); + model.smokeValue = Read(2); + model.infraredValue = Read(3); + return model; + } + + private bool Read(int sensor) => TrainingBoard.DigitalInputs.TryGetValue(_channels[sensor], out var value) && value; + + /// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯),通道号有效时返回 true + public bool OnOff(int DO, bool IsOpen) + { + if (DO < 0 || DO > 4) return false; + SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 }); + return true; + } + + public void Close() => _portName = null; +} + +public class ZigBeeHelper +{ + private string? _portName; + + public void Open(string PortName) => _portName = PortName; + + /// 控制 ZigBee 继电器节点 0、1,通道号有效时返回 true + public bool OnOff(int RelayNum, bool IsOpen) + { + if (RelayNum < 0 || RelayNum > 1) return false; + SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 }); + return true; + } + + public void Close() => _portName = null; +} diff --git a/runtime/Sim.Devices/DeviceCtrlLibrary/Models.cs b/runtime/Sim.Devices/DeviceCtrlLibrary/Models.cs new file mode 100644 index 0000000..730d472 --- /dev/null +++ b/runtime/Sim.Devices/DeviceCtrlLibrary/Models.cs @@ -0,0 +1,30 @@ +// 与教材 dll 一致:ADAM-4017 的数值全部是字符串,ADAM-4150 的数值全部是 bool。 +namespace DeviceCtrlLibrary.Model; + +public class ADAM4017ResModel +{ + public string temperatureValue { get; set; } + public string humidityValue { get; set; } + public string lightValue { get; set; } + public string airPressureValue { get; set; } + public string airQualityValue { get; set; } + public string CO2Value { get; set; } + public string windValue { get; set; } +} + +public class ADAM4150ResModel +{ + public bool bodyInfraredValue { get; set; } + public bool fireValue { get; set; } + public bool smokeValue { get; set; } + public bool infraredValue { get; set; } +} + +public class ZigBeeResModel +{ + public string waterLevelValue { get; set; } + public string waterTemperatureValue { get; set; } + public string soilMoistureValue { get; set; } + // 拼写与教材 dll 一致 + public string soilTemperatuerValue { get; set; } +} diff --git a/runtime/Sim.Devices/DeviceCtrlLibrary/TrainingBoard.cs b/runtime/Sim.Devices/DeviceCtrlLibrary/TrainingBoard.cs new file mode 100644 index 0000000..bae64cd --- /dev/null +++ b/runtime/Sim.Devices/DeviceCtrlLibrary/TrainingBoard.cs @@ -0,0 +1,25 @@ +using System.Collections.Concurrent; + +namespace DeviceCtrlLibrary; + +/// +/// 虚拟实训台的设备状态。第 0 阶段先放在运行器进程里、用固定的默认值; +/// 第 1 阶段改为向 Bun 服务器上的实训台查询,数值由网页上的滑块和开关控制。 +/// +internal static class TrainingBoard +{ + /// ADAM-4017 各通道的读数(Vin0 温度、Vin1 湿度、Vin2 光照……),按教材格式保存为整数 + public static readonly ConcurrentDictionary AnalogInputs = new() + { + [0] = 25, // 温度 ℃ + [1] = 60, // 湿度 % + [2] = 500, // 光照 lx + [3] = 101, // 气压 kPa + [4] = 60, // 空气质量 + [5] = 600, // CO2 ppm + [6] = 20, // 风速 + }; + + /// ADAM-4150 数字量输入(DI0 人体红外、DI1 火焰、DI2 烟雾、DI3 红外对射) + public static readonly ConcurrentDictionary DigitalInputs = new(); +} diff --git a/runtime/Sim.Devices/Sim.Devices.csproj b/runtime/Sim.Devices/Sim.Devices.csproj new file mode 100644 index 0000000..c75e217 --- /dev/null +++ b/runtime/Sim.Devices/Sim.Devices.csproj @@ -0,0 +1,12 @@ + + + + Sim.Devices + DeviceCtrlLibrary + $(NoWarn);CS1591;CS8618;IDE1006 + + + + + diff --git a/runtime/Sim.Devices/WinFormsDeviceControlLibrary/LED.cs b/runtime/Sim.Devices/WinFormsDeviceControlLibrary/LED.cs new file mode 100644 index 0000000..0e2aea6 --- /dev/null +++ b/runtime/Sim.Devices/WinFormsDeviceControlLibrary/LED.cs @@ -0,0 +1,36 @@ +using System.Drawing; +using System.Text.Json.Nodes; +using System.Windows.Forms; + +namespace WinFormsDeviceControlLibrary; + +/// +/// 教材控件库的 LED 显示屏:默认黑底、宋体 70pt 红字,setLedText 后文字从右向左滚动(滚动由浏览器完成)。 +/// +[ClientType("WinFormsDeviceControlLibrary.LED")] +public class LED : UserControl +{ + private string _ledText = ""; + + public LED() + { + BackColor = Color.Black; + ForeColor = Color.Red; + Font = new Font("宋体", 70F); + } + + protected override Size DefaultSize => new(800, 130); + + public void setLedText(string LedText) + { + EnsureThreadAccess(); + _ledText = LedText ?? ""; + NotifyChanged("LedText", _ledText); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["LedText"] = _ledText; + } +} diff --git a/runtime/Sim.Runner/CompileHints.cs b/runtime/Sim.Runner/CompileHints.cs new file mode 100644 index 0000000..95b145f --- /dev/null +++ b/runtime/Sim.Runner/CompileHints.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis; + +namespace Sim.Runner; + +/// +/// 针对教材上常见写法错误的中文提示(书上代码有 True/False 大写、Catch(EventArgs ex) 等印刷错误,学生照抄后会遇到)。 +/// 只在编译器的原始报错之外补充一句说明,不改变报错本身。 +/// +internal static class CompileHints +{ + public static string? For(Diagnostic diagnostic) + { + var source = diagnostic.Location.SourceTree?.GetText().ToString(diagnostic.Location.SourceSpan) ?? ""; + return (diagnostic.Id, source) switch + { + ("CS0103", "True" or "False") => $"C# 的布尔值是小写的 {source.ToLowerInvariant()}。", + ("CS0103", "Catch") or ("CS0246", "Catch") => "catch 要小写,括号里写异常类型,例如 catch (Exception ex)。", + ("CS0246", var name) when name.StartsWith("bool") || name.StartsWith("private") || name.StartsWith("public") || name.StartsWith("string") || name.StartsWith("int") => + $"“{name}”像是类型和名字之间少了一个空格。", + ("CS0103", _) => $"找不到“{source}”。检查拼写和大小写(C# 区分大小写),以及控件的 Name 是否和代码里一致。", + ("CS1002", _) => "C# 每条语句末尾要有英文分号 ;", + ("CS1056", _) or ("CS1525", _) when source.Length == 1 && source[0] > 127 => "这里出现了中文标点,C# 代码里的符号要用英文半角。", + ("CS0029", _) => "等号两边的类型不一致。如果要把文本框里的文字变成数字,可以用 int.Parse(...) 或 Convert.ToInt32(...)。", + _ => null, + }; + } +} diff --git a/runtime/Sim.Runner/CompileServer.cs b/runtime/Sim.Runner/CompileServer.cs new file mode 100644 index 0000000..eecaf44 --- /dev/null +++ b/runtime/Sim.Runner/CompileServer.cs @@ -0,0 +1,84 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Sim.Runner; + +/// +/// 常驻编译服务:从标准输入读取 {"id":…,"dir":…},编译该目录下的学生工程, +/// 成功时把 StudentApp.dll / StudentApp.pdb 写到同一目录,回复 {"id":…,"type":"compiled",…}。 +/// 编译器常驻,第一次之后的编译不再有加载和预热开销。 +/// +internal static class CompileServer +{ + public const string AssemblyFile = "StudentApp.dll"; + public const string PdbFile = "StudentApp.pdb"; + + private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + public static int Run() + { + using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false)); + using var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true }; + + WarmUp(); + output.WriteLine(new JsonObject { ["type"] = "ready" }.ToJsonString(JsonOptions)); + + while (input.ReadLine() is { } line) + { + JsonObject reply; + JsonNode? id = null; + try + { + var request = JsonNode.Parse(line)!.AsObject(); + id = request["id"]?.DeepClone(); + reply = Compile((string?)request["dir"] ?? throw new ArgumentException("缺少 dir")); + } + catch (Exception ex) + { + reply = new JsonObject { ["type"] = "compileFailed", ["message"] = ex.Message }; + } + reply["id"] = id; + output.WriteLine(reply.ToJsonString(JsonOptions)); + } + return 0; + } + + private static JsonObject Compile(string dir) + { + var watch = Stopwatch.StartNew(); + var result = StudentCompiler.Compile(dir); + if (result.Success) + { + File.WriteAllBytes(Path.Combine(dir, AssemblyFile), result.Assembly); + File.WriteAllBytes(Path.Combine(dir, PdbFile), result.Pdb); + } + return new JsonObject + { + ["type"] = "compiled", + ["ok"] = result.Success, + ["diagnostics"] = result.Diagnostics, + ["ms"] = watch.ElapsedMilliseconds, + }; + } + + /// 启动时编译一个小程序,让 Roslyn 完成加载和 JIT,学生第一次点"运行"时就不用等 + private static void WarmUp() + { + var dir = Directory.CreateTempSubdirectory("sim-warmup-"); + try + { + File.WriteAllText(Path.Combine(dir.FullName, "Program.cs"), + "using System.Windows.Forms; static class P { static void Main() { var f = new Form(); f.Text = \"预热\"; } }"); + StudentCompiler.Compile(dir.FullName); + } + finally + { + dir.Delete(recursive: true); + } + } +} diff --git a/runtime/Sim.Runner/ConsoleForwarder.cs b/runtime/Sim.Runner/ConsoleForwarder.cs new file mode 100644 index 0000000..72982d2 --- /dev/null +++ b/runtime/Sim.Runner/ConsoleForwarder.cs @@ -0,0 +1,44 @@ +using System.IO; +using System.Text; +using Sim.Host; + +namespace Sim.Runner; + +/// 把学生程序的 Console 输出按行转发给浏览器(标准输出本身留给协议消息使用) +internal sealed class ConsoleForwarder : TextWriter +{ + private readonly StringBuilder _buffer = new(); + + public override Encoding Encoding => Encoding.UTF8; + + public override void Write(char value) + { + lock (_buffer) + { + _buffer.Append(value); + if (value == '\n') FlushBuffer(); + } + } + + public override void Write(string? value) + { + if (value == null) return; + lock (_buffer) + { + _buffer.Append(value); + if (value.Contains('\n')) FlushBuffer(); + } + } + + public override void Flush() + { + lock (_buffer) FlushBuffer(); + } + + private void FlushBuffer() + { + if (_buffer.Length == 0) return; + SimHost.SendConsole(_buffer.ToString()); + _buffer.Clear(); + } +} diff --git a/runtime/Sim.Runner/Program.cs b/runtime/Sim.Runner/Program.cs new file mode 100644 index 0000000..94a8c43 --- /dev/null +++ b/runtime/Sim.Runner/Program.cs @@ -0,0 +1,21 @@ +using System; +using System.IO; +using Sim.Runner; + +// 两种模式,都通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条): +// dotnet Sim.Runner.dll compile-server 常驻编译服务,全服务器共用一个,编译学生工程并把程序集写回工程目录 +// dotnet Sim.Runner.dll run <工程目录> 运行已编译的学生程序,每次运行一个进程 +// 运行模式不加载 Roslyn,单个学生程序的内存占用因此小得多。 + +return args switch +{ + ["compile-server"] => CompileServer.Run(), + ["run", var dir] when Directory.Exists(dir) => StudentProgramHost.Run(dir), + _ => Usage(), +}; + +static int Usage() +{ + Console.Error.WriteLine("用法:Sim.Runner compile-server | Sim.Runner run <工程目录>"); + return 2; +} diff --git a/runtime/Sim.Runner/Sim.Runner.csproj b/runtime/Sim.Runner/Sim.Runner.csproj new file mode 100644 index 0000000..a073753 --- /dev/null +++ b/runtime/Sim.Runner/Sim.Runner.csproj @@ -0,0 +1,19 @@ + + + + Exe + Sim.Runner + Sim.Runner + + zh-Hans + true + + + + + + + + + diff --git a/runtime/Sim.Runner/StudentCompiler.cs b/runtime/Sim.Runner/StudentCompiler.cs new file mode 100644 index 0000000..ccd371f --- /dev/null +++ b/runtime/Sim.Runner/StudentCompiler.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.CodeAnalysis.Text; + +namespace Sim.Runner; + +/// 用 Roslyn 在内存里编译学生工程,报错信息用简体中文 +internal static class StudentCompiler +{ + private static readonly CultureInfo Chinese = CultureInfo.GetCultureInfo("zh-Hans"); + + /// + /// 框架里不能给学生代码引用的程序集:System.Drawing.dll 门面把 Font、Image 等转发到 Linux 不支持的 + /// System.Drawing.Common,会和仿真库里的同名类型冲突。 + /// + private static readonly HashSet ExcludedFrameworkAssemblies = new(StringComparer.OrdinalIgnoreCase) + { + "System.Drawing.dll", + }; + + private static readonly string[] SimAssemblies = { "Sim.WinForms.dll", "Sim.Devices.dll" }; + + public sealed record Result(bool Success, byte[] Assembly, byte[] Pdb, JsonArray Diagnostics); + + public static Result Compile(string projectDir) + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Latest); + var trees = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories) + .Where(path => !IsBuildOutput(projectDir, path)) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => CSharpSyntaxTree.ParseText( + SourceText.From(File.ReadAllText(path), Encoding.UTF8), + parseOptions, + path: Path.GetRelativePath(projectDir, path))) + .ToList(); + + var options = new CSharpCompilationOptions(OutputKind.WindowsApplication) + .WithOptimizationLevel(OptimizationLevel.Debug) + .WithNullableContextOptions(NullableContextOptions.Disable) + .WithAllowUnsafe(false) + .WithConcurrentBuild(true); + + var compilation = CSharpCompilation.Create("StudentApp", trees, References.Value, options); + + using var peStream = new MemoryStream(); + using var pdbStream = new MemoryStream(); + var emit = compilation.Emit(peStream, pdbStream, + options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb)); + + var diagnostics = new JsonArray(); + foreach (var diagnostic in emit.Diagnostics + .Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning) + .OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1)) + { + diagnostics.Add(ToJson(diagnostic)); + } + return new Result(emit.Success, peStream.ToArray(), pdbStream.ToArray(), diagnostics); + } + + private static JsonObject ToJson(Diagnostic diagnostic) + { + var span = diagnostic.Location.GetMappedLineSpan(); + var message = diagnostic.GetMessage(Chinese); + var json = new JsonObject + { + ["file"] = span.Path, + ["line"] = span.StartLinePosition.Line + 1, + ["column"] = span.StartLinePosition.Character + 1, + ["severity"] = diagnostic.Severity == DiagnosticSeverity.Error ? "error" : "warning", + ["code"] = diagnostic.Id, + ["message"] = message, + }; + var hint = CompileHints.For(diagnostic); + if (hint != null) json["hint"] = hint; + return json; + } + + private static bool IsBuildOutput(string projectDir, string path) + { + var relative = Path.GetRelativePath(projectDir, path).Replace('\\', '/'); + return relative.StartsWith("bin/") || relative.StartsWith("obj/") || relative.Contains("/bin/") || relative.Contains("/obj/"); + } + + /// 学生代码可引用的程序集:.NET 框架(去掉冲突的门面)+ 仿真库 + private static readonly Lazy> References = new(() => + { + var frameworkDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + var framework = Directory.EnumerateFiles(frameworkDir, "*.dll") + .Where(path => !ExcludedFrameworkAssemblies.Contains(Path.GetFileName(path))) + .Where(IsManagedAssembly); + var sim = SimAssemblies.Select(name => Path.Combine(AppContext.BaseDirectory, name)); + return framework.Concat(sim).Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)).ToList(); + }); + + private static bool IsManagedAssembly(string path) + { + try + { + AssemblyName.GetAssemblyName(path); + return true; + } + catch (BadImageFormatException) + { + return false; // 框架目录里的原生库 + } + } +} diff --git a/runtime/Sim.Runner/StudentProgramHost.cs b/runtime/Sim.Runner/StudentProgramHost.cs new file mode 100644 index 0000000..fb2a6cc --- /dev/null +++ b/runtime/Sim.Runner/StudentProgramHost.cs @@ -0,0 +1,117 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Runtime.Loader; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Windows.Forms; +using Sim.Host; + +namespace Sim.Runner; + +/// +/// 运行一个已编译的学生程序:在模拟的界面线程上执行 Main,主线程从标准输入读取浏览器的操作。 +/// 这个类不能引用 Roslyn 的任何类型,否则运行模式也会加载编译器。 +/// +internal static class StudentProgramHost +{ + public static int Run(string dir) + { + var processStart = Process.GetCurrentProcess().StartTime; + SimHost.Initialize(Console.OpenStandardOutput()); + var console = new ConsoleForwarder(); + Console.SetOut(console); + Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容 + + // 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出 + var zhCN = CultureInfo.GetCultureInfo("zh-CN"); + CultureInfo.DefaultThreadCurrentCulture = zhCN; + CultureInfo.DefaultThreadCurrentUICulture = zhCN; + CultureInfo.CurrentCulture = zhCN; + CultureInfo.CurrentUICulture = zhCN; + + var assembly = AssemblyLoadContext.Default.LoadFromStream( + new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.AssemblyFile))), + new MemoryStream(File.ReadAllBytes(Path.Combine(dir, CompileServer.PdbFile)))); + SimHost.StudentAssembly = assembly; + var entryPoint = assembly.EntryPoint; + if (entryPoint == null) + { + SimHost.Send(new JsonObject { ["type"] = "exception", ["message"] = "程序中找不到 Main 方法", ["detail"] = "" }); + return 1; + } + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + // 学生创建的后台线程抛出未处理异常时,和真实程序一样整个程序结束 + if (e.ExceptionObject is Exception ex) SimHost.ReportException(ex); + console.Flush(); + }; + + var uiThread = new Thread(() => + { + SimHost.Ui.BindToCurrentThread(); + SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext()); + SimHost.Send(new JsonObject { ["type"] = "started", ["ms"] = (long)(DateTime.Now - processStart).TotalMilliseconds }); + try + { + var parameters = entryPoint.GetParameters().Length == 0 ? null : new object[] { Array.Empty() }; + entryPoint.Invoke(null, parameters); + } + catch (Exception ex) + { + SimHost.ReportException(ex); + } + finally + { + console.Flush(); + SimHost.FlushChanges(); + Environment.Exit(0); + } + }) { Name = "UI" }; + uiThread.Start(); + + ReadActions(); + // 服务器关闭了标准输入:结束运行 + Environment.Exit(0); + return 0; + } + + /// 读取浏览器发来的操作,排队到界面线程执行 + private static void ReadActions() + { + using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false)); + while (input.ReadLine() is { } line) + { + JsonObject? message; + try + { + message = JsonNode.Parse(line) as JsonObject; + } + catch (JsonException) + { + continue; + } + switch ((string?)message?["type"]) + { + case "action": + if (SimHost.FindControl((string?)message!["id"] ?? "") is { } control) + { + var action = UserAction.FromJson(message); + SimHost.Ui.Post(() => control.PerformUserAction(action)); + } + break; + case "reply": + SimHost.CompleteRequest((int?)message!["requestId"] ?? 0, message["result"]?.DeepClone()); + break; + case "stop": + Environment.Exit(0); + break; + } + } + } +} diff --git a/runtime/Sim.WinForms/Drawing/Font.cs b/runtime/Sim.WinForms/Drawing/Font.cs new file mode 100644 index 0000000..251969c --- /dev/null +++ b/runtime/Sim.WinForms/Drawing/Font.cs @@ -0,0 +1,119 @@ +// System.Drawing.Common 在 Linux 上不受支持。Point、Size、Color、SystemColors 等框架自带(System.Drawing.Primitives), +// 这里只补上学生代码会用到、框架里又没有的类型。字体只记录名称和样式,真正的渲染在浏览器里完成。 +// 注意:框架的 System.Drawing.dll 门面把 Font 等转发到 System.Drawing.Common,编译学生代码时不能引用它。 +using System; + +namespace System.Drawing; + +public enum ContentAlignment +{ + TopLeft = 1, + TopCenter = 2, + TopRight = 4, + MiddleLeft = 16, + MiddleCenter = 32, + MiddleRight = 64, + BottomLeft = 256, + BottomCenter = 512, + BottomRight = 1024, +} + +[Flags] +public enum FontStyle +{ + Regular = 0, + Bold = 1, + Italic = 2, + Underline = 4, + Strikeout = 8, +} + +public enum GraphicsUnit +{ + World = 0, + Display = 1, + Pixel = 2, + Point = 3, + Inch = 4, + Document = 5, + Millimeter = 6, +} + +public sealed class FontFamily +{ + public FontFamily(string name) => Name = name; + + public string Name { get; } + + public override string ToString() => $"[FontFamily: Name={Name}]"; +} + +public sealed class Font : IDisposable, ICloneable +{ + public Font(string familyName, float emSize) + : this(familyName, emSize, FontStyle.Regular, GraphicsUnit.Point) { } + + public Font(string familyName, float emSize, FontStyle style) + : this(familyName, emSize, style, GraphicsUnit.Point) { } + + public Font(string familyName, float emSize, GraphicsUnit unit) + : this(familyName, emSize, FontStyle.Regular, unit) { } + + public Font(string familyName, float emSize, FontStyle style, GraphicsUnit unit) + { + if (emSize <= 0 || float.IsNaN(emSize) || float.IsInfinity(emSize)) + throw new ArgumentException($"“emSize”的值“{emSize}”无效。“emSize”应大于 0 且小于或等于 System.Single.MaxValue。", nameof(emSize)); + Name = string.IsNullOrWhiteSpace(familyName) ? "Microsoft Sans Serif" : familyName; + Size = emSize; + Style = style; + Unit = unit; + } + + public Font(string familyName, float emSize, FontStyle style, GraphicsUnit unit, byte gdiCharSet) + : this(familyName, emSize, style, unit) => GdiCharSet = gdiCharSet; + + public Font(string familyName, float emSize, FontStyle style, GraphicsUnit unit, byte gdiCharSet, bool gdiVerticalFont) + : this(familyName, emSize, style, unit, gdiCharSet) { } + + public Font(FontFamily family, float emSize) : this(family.Name, emSize) { } + + public Font(FontFamily family, float emSize, FontStyle style) : this(family.Name, emSize, style) { } + + public Font(Font prototype, FontStyle newStyle) : this(prototype.Name, prototype.Size, newStyle, prototype.Unit) { } + + public string Name { get; } + public float Size { get; } + public FontStyle Style { get; } + public GraphicsUnit Unit { get; } + public byte GdiCharSet { get; } = 1; + public FontFamily FontFamily => new(Name); + + public bool Bold => (Style & FontStyle.Bold) != 0; + public bool Italic => (Style & FontStyle.Italic) != 0; + public bool Underline => (Style & FontStyle.Underline) != 0; + public bool Strikeout => (Style & FontStyle.Strikeout) != 0; + + /// 按 96 DPI 换算成磅 + public float SizeInPoints => Unit switch + { + GraphicsUnit.Pixel => Size * 72f / 96f, + GraphicsUnit.Inch => Size * 72f, + GraphicsUnit.Millimeter => Size * 72f / 25.4f, + GraphicsUnit.Document => Size * 72f / 300f, + _ => Size, + }; + + /// 大致的行高(像素),中文字体约为字号的 1.33 倍 + public int Height => (int)Math.Ceiling(SizeInPoints * 96f / 72f * 1.14f); + + public object Clone() => new Font(Name, Size, Style, Unit, GdiCharSet); + + public void Dispose() { } + + public override string ToString() => $"[Font: Name={Name}, Size={Size}, Units={(int)Unit}, GdiCharSet={GdiCharSet}, GdiVerticalFont=False]"; + + public override bool Equals(object? obj) => + obj is Font other && other.Name == Name && other.Size == Size && other.Style == Style && other.Unit == Unit; + + public override int GetHashCode() => HashCode.Combine(Name, Size, Style, Unit); +} diff --git a/runtime/Sim.WinForms/Forms/Application.cs b/runtime/Sim.WinForms/Forms/Application.cs new file mode 100644 index 0000000..3370805 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Application.cs @@ -0,0 +1,56 @@ +using System.Collections; +using System.Collections.Generic; +using Sim.Host; + +namespace System.Windows.Forms; + +public static class Application +{ + internal static readonly List
OpenFormsList = new(); + private static bool _exitRequested; + + public static FormCollection OpenForms { get; } = new(); + + public static string StartupPath => AppContext.BaseDirectory.TrimEnd('/', '\\'); + public static string ExecutablePath => System.IO.Path.Combine(StartupPath, "app.exe"); + public static string ProductName => "WindowsFormsApplication"; + + public static void EnableVisualStyles() { } + + public static void SetCompatibleTextRenderingDefault(bool defaultValue) { } + + public static void SetHighDpiMode(object mode) { } + + /// 显示主窗体并运行消息循环,直到主窗体关闭或调用 Application.Exit() + public static void Run(Form mainForm) + { + mainForm.Show(); + SimHost.Ui.RunUntil(() => _exitRequested || mainForm.IsClosed); + Exit(); + } + + public static void Run() + { + SimHost.Ui.RunUntil(() => _exitRequested); + } + + public static void Exit() + { + _exitRequested = true; + foreach (var form in OpenFormsList.ToArray()) form.CloseCore(CloseReason.ApplicationExitCall); + SimHost.Ui.Wake(); + } + + public static void ExitThread() => Exit(); + + public static void DoEvents() => SimHost.Ui.DoEvents(); + + public sealed class FormCollection : IEnumerable + { + public int Count => OpenFormsList.Count; + public Form this[int index] => OpenFormsList[index]; + public Form? this[string name] => OpenFormsList.Find(f => f.Name == name); + public IEnumerator GetEnumerator() => OpenFormsList.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/runtime/Sim.WinForms/Forms/Button.cs b/runtime/Sim.WinForms/Forms/Button.cs new file mode 100644 index 0000000..d3d874a --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Button.cs @@ -0,0 +1,47 @@ +using System.Drawing; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +public abstract class ButtonBase : Control +{ + private FlatStyle _flatStyle = FlatStyle.Standard; + + public bool UseVisualStyleBackColor { get; set; } = true; + public bool UseMnemonic { get; set; } = true; + public ContentAlignment TextAlign { get; set; } = ContentAlignment.MiddleCenter; + + public FlatStyle FlatStyle + { + get => _flatStyle; + set => SetProp(ref _flatStyle, value, nameof(FlatStyle)); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["FlatStyle"] = _flatStyle.ToString(); + } +} + +public class Button : ButtonBase, IButtonControl +{ + protected override Size DefaultSize => new(75, 23); + + public DialogResult DialogResult { get; set; } + + public void NotifyDefault(bool value) { } + + public void PerformClick() + { + if (Enabled && Visible) OnClick(EventArgs.Empty); + } + + protected override void OnClick(EventArgs e) + { + // 按钮设置了 DialogResult 时,点击会把结果交给所在的模态窗体并关闭它 + if (DialogResult != DialogResult.None && FindForm() is { } form) form.DialogResult = DialogResult; + base.OnClick(e); + } +} diff --git a/runtime/Sim.WinForms/Forms/ComboBox.cs b/runtime/Sim.WinForms/Forms/ComboBox.cs new file mode 100644 index 0000000..495b2a5 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/ComboBox.cs @@ -0,0 +1,176 @@ +using System.Collections; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +public class ComboBox : Control +{ + private int _selectedIndex = -1; + private ComboBoxStyle _dropDownStyle = ComboBoxStyle.DropDown; + + public ComboBox() => Items = new ObjectCollection(this); + + protected override Size DefaultSize => new(121, 20); + + public ObjectCollection Items { get; } + + public bool FormattingEnabled { get; set; } + public int MaxDropDownItems { get; set; } = 8; + public int DropDownWidth { get; set; } = 121; + public int DropDownHeight { get; set; } = 106; + public bool Sorted { get; set; } + public bool DroppedDown { get; set; } + + public ComboBoxStyle DropDownStyle + { + get => _dropDownStyle; + set => SetProp(ref _dropDownStyle, value, nameof(DropDownStyle)); + } + + public int SelectedIndex + { + get => _selectedIndex; + set + { + if (value < -1 || value >= Items.Count) + throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“SelectedIndex”无效。"); + if (value == _selectedIndex) return; + SetProp(ref _selectedIndex, value, nameof(SelectedIndex)); + // 选中项变化时 Text 随之变化(先 TextChanged,再 SelectedIndexChanged,与 WinForms 一致) + Text = value >= 0 ? Items[value]?.ToString() ?? "" : ""; + OnSelectedIndexChanged(EventArgs.Empty); + OnSelectedValueChanged(EventArgs.Empty); + } + } + + public object? SelectedItem + { + get => _selectedIndex >= 0 ? Items[_selectedIndex] : null; + set => SelectedIndex = value == null ? -1 : Items.IndexOf(value); + } + + public string SelectedText { get; set; } = ""; + + public event EventHandler? SelectedIndexChanged; + public event EventHandler? SelectedValueChanged; + public event EventHandler? DropDown; + public event EventHandler? DropDownClosed; + + protected virtual void OnSelectedIndexChanged(EventArgs e) => SelectedIndexChanged?.Invoke(this, e); + protected virtual void OnSelectedValueChanged(EventArgs e) => SelectedValueChanged?.Invoke(this, e); + protected virtual void OnDropDown(EventArgs e) => DropDown?.Invoke(this, e); + protected virtual void OnDropDownClosed(EventArgs e) => DropDownClosed?.Invoke(this, e); + + public void BeginUpdate() { } + public void EndUpdate() { } + + protected override void OnUserAction(UserAction action) + { + switch (action.Action) + { + case "dropDown": + // 教材在 Click 事件里刷新串口列表,所以 Click 必须在列表展开之前触发 + OnClick(EventArgs.Empty); + OnDropDown(EventArgs.Empty); + break; + case "select": + if (action.Index >= 0 && action.Index < Items.Count) SelectedIndex = action.Index; + OnDropDownClosed(EventArgs.Empty); + break; + case "text" when _dropDownStyle != ComboBoxStyle.DropDownList: + SetTextFromUser(action.Value ?? ""); + var match = Items.IndexOf(Text); + if (match != _selectedIndex) _selectedIndex = match; + break; + default: + base.OnUserAction(action); + break; + } + } + + internal void ItemsChanged() + { + if (_selectedIndex >= Items.Count) _selectedIndex = -1; + NotifyChanged(nameof(Items), Items.Select(i => i?.ToString() ?? "").ToArray()); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["Items"] = PropValue.From(Items.Select(i => i?.ToString() ?? "").ToArray()); + props["SelectedIndex"] = _selectedIndex; + props["DropDownStyle"] = _dropDownStyle.ToString(); + } + + public class ObjectCollection : IList, IEnumerable + { + private readonly List _items = new(); + private readonly ComboBox _owner; + + public ObjectCollection(ComboBox owner) => _owner = owner; + + public int Count => _items.Count; + public bool IsReadOnly => false; + bool IList.IsFixedSize => false; + bool ICollection.IsSynchronized => false; + object ICollection.SyncRoot => this; + + public object? this[int index] + { + get => _items[index]; + set + { + _items[index] = value; + _owner.ItemsChanged(); + } + } + + public int Add(object? item) + { + if (item == null) throw new ArgumentNullException(nameof(item), "值不能为 null。"); + _items.Add(item); + _owner.ItemsChanged(); + return _items.Count - 1; + } + + public void AddRange(object[] items) + { + _items.AddRange(items); + _owner.ItemsChanged(); + } + + public void Insert(int index, object? item) + { + _items.Insert(index, item); + _owner.ItemsChanged(); + } + + public void Remove(object? item) + { + if (_items.Remove(item)) _owner.ItemsChanged(); + } + + public void RemoveAt(int index) + { + _items.RemoveAt(index); + _owner.ItemsChanged(); + } + + public void Clear() + { + _items.Clear(); + _owner.SelectedIndex = -1; + _owner.ItemsChanged(); + } + + public bool Contains(object? item) => _items.Contains(item); + public int IndexOf(object? item) => _items.IndexOf(item); + public void CopyTo(Array array, int index) => ((ICollection)_items).CopyTo(array, index); + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/runtime/Sim.WinForms/Forms/Containers.cs b/runtime/Sim.WinForms/Forms/Containers.cs new file mode 100644 index 0000000..e070199 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Containers.cs @@ -0,0 +1,61 @@ +using System.Drawing; +using System.Text.Json.Nodes; + +namespace System.Windows.Forms; + +public class ScrollableControl : Control +{ + public bool AutoScroll { get; set; } +} + +public class ContainerControl : ScrollableControl +{ + public SizeF AutoScaleDimensions { get; set; } + public AutoScaleMode AutoScaleMode { get; set; } = AutoScaleMode.Inherit; + public Control? ActiveControl { get; set; } +} + +public class UserControl : ContainerControl +{ + protected override Size DefaultSize => new(150, 150); + + public event EventHandler? Load; + + protected virtual void OnLoad(EventArgs e) => Load?.Invoke(this, e); + + private bool _loaded; + + /// UserControl 的 Load 在第一次随窗体显示时触发 + internal void RaiseLoadOnce() + { + if (_loaded) return; + _loaded = true; + OnLoad(EventArgs.Empty); + } +} + +public class Panel : ScrollableControl +{ + private BorderStyle _borderStyle; + + protected override Size DefaultSize => new(200, 100); + + public BorderStyle BorderStyle + { + get => _borderStyle; + set => SetProp(ref _borderStyle, value, nameof(BorderStyle)); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["BorderStyle"] = _borderStyle.ToString(); + } +} + +public class GroupBox : Control +{ + protected override Size DefaultSize => new(200, 100); + + public FlatStyle FlatStyle { get; set; } = FlatStyle.Standard; +} diff --git a/runtime/Sim.WinForms/Forms/Control.cs b/runtime/Sim.WinForms/Forms/Control.cs new file mode 100644 index 0000000..318ca66 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Control.cs @@ -0,0 +1,503 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Reflection; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +/// +/// 仿真控件的基类。属性保存在本进程里;控件所在窗体显示后,属性变化以 patch 消息发给浏览器渲染。 +/// Font、BackColor、ForeColor 是"环境属性":没有显式设置时沿用父控件的值,只把显式设置的值发给浏览器。 +/// +public class Control : Component, IWin32Window +{ + private static readonly Font DefaultFontValue = new("宋体", 9F); + + private string _name = ""; + private string _text = ""; + private Point _location; + private Size _size; + private bool _visible = true; + private bool _enabled = true; + private Color _backColor; + private Color _foreColor; + private Font? _font; + private bool _autoSize; + private DockStyle _dock; + private AnchorStyles _anchor = AnchorStyles.Top | AnchorStyles.Left; + private Control? _parent; + + public Control() + { + SimId = SimHost.NewControlId(); + SimHost.Register(this); + _size = DefaultSize; + Controls = CreateControlsInstance(); + } + + public Control(string text) : this() => _text = text ?? ""; + + /// 平台内部使用:控件在浏览器端的编号 + [EditorBrowsable(EditorBrowsableState.Never)] + public string SimId { get; } + + /// 为 true 时,从非界面线程修改已显示控件的属性会抛出异常(和在 VS 里按 F5 调试时一致) + public static bool CheckForIllegalCrossThreadCalls { get; set; } = true; + + public static Color DefaultBackColor => Color.FromArgb(240, 240, 240); + public static Color DefaultForeColor => Color.Black; + public static Font DefaultFont => DefaultFontValue; + + protected virtual Size DefaultSize => Size.Empty; + + // ---------- 层级 ---------- + + public ControlCollection Controls { get; } + + protected virtual ControlCollection CreateControlsInstance() => new(this); + + public Control? Parent + { + get => _parent; + set + { + if (value == _parent) return; + _parent?.Controls.Remove(this); + value?.Controls.Add(this); + } + } + + internal void SetParentInternal(Control? parent) => _parent = parent; + + public Form? FindForm() + { + for (var c = _parent; c != null; c = c._parent) + if (c is Form form) return form; + return null; + } + + /// 控件已经显示在浏览器里(所在窗体已打开)时,属性变化才需要发送 + internal virtual bool IsAttached + { + get + { + var root = this; + while (root._parent != null) root = root._parent; + return root is Form { IsOpenInBrowser: true }; + } + } + + // ---------- 常用属性 ---------- + + public string Name + { + get => _name; + set => _name = value ?? ""; + } + + public virtual string Text + { + get => _text; + set + { + value ??= ""; + if (_text == value) return; + EnsureThreadAccess(); + _text = value; + NotifyChanged(nameof(Text), value); + OnTextChanged(EventArgs.Empty); + } + } + + /// 来自浏览器输入的文字:只修改值并触发事件,不再回传给浏览器(避免打字时光标跳动) + internal void SetTextFromUser(string value) + { + if (_text == value) return; + _text = value; + OnTextChanged(EventArgs.Empty); + } + + public Point Location + { + get => _location; + set => SetProp(ref _location, value, nameof(Location)); + } + + public Size Size + { + get => _size; + set => SetProp(ref _size, value, nameof(Size)); + } + + public int Left { get => _location.X; set => Location = new Point(value, _location.Y); } + public int Top { get => _location.Y; set => Location = new Point(_location.X, value); } + public int Width { get => _size.Width; set => Size = new Size(value, _size.Height); } + public int Height { get => _size.Height; set => Size = new Size(_size.Width, value); } + public int Right => Left + Width; + public int Bottom => Top + Height; + + public Rectangle Bounds + { + get => new(_location, _size); + set + { + Location = value.Location; + Size = value.Size; + } + } + + public virtual Size ClientSize + { + get => Size; + set => Size = value; + } + + public bool Visible + { + get => _visible; + set + { + if (SetProp(ref _visible, value, nameof(Visible))) OnVisibleChanged(EventArgs.Empty); + } + } + + public bool Enabled + { + get => _enabled; + set + { + if (SetProp(ref _enabled, value, nameof(Enabled))) OnEnabledChanged(EventArgs.Empty); + } + } + + public virtual Color BackColor + { + get => !_backColor.IsEmpty ? _backColor : _parent?.BackColor ?? DefaultBackColor; + set => SetProp(ref _backColor, value, nameof(BackColor)); + } + + public virtual Color ForeColor + { + get => !_foreColor.IsEmpty ? _foreColor : _parent?.ForeColor ?? DefaultForeColor; + set => SetProp(ref _foreColor, value, nameof(ForeColor)); + } + + public virtual Font Font + { + get => _font ?? _parent?.Font ?? DefaultFont; + set => SetProp(ref _font, value, nameof(Font)); + } + + public virtual bool AutoSize + { + get => _autoSize; + set => SetProp(ref _autoSize, value, nameof(AutoSize)); + } + + public DockStyle Dock + { + get => _dock; + set => SetProp(ref _dock, value, nameof(Dock)); + } + + public AnchorStyles Anchor + { + get => _anchor; + set => SetProp(ref _anchor, value, nameof(Anchor)); + } + + // 以下属性只保存值,暂不影响浏览器显示 + public int TabIndex { get; set; } + public bool TabStop { get; set; } = true; + public object? Tag { get; set; } + public Padding Margin { get; set; } = new(3); + public Padding Padding { get; set; } + public ImeMode ImeMode { get; set; } = ImeMode.NoControl; + public virtual RightToLeft RightToLeft { get; set; } = RightToLeft.No; + public virtual bool AllowDrop { get; set; } + public bool CausesValidation { get; set; } = true; + + public bool Focused { get; private set; } + + [EditorBrowsable(EditorBrowsableState.Never)] + public IntPtr Handle => new(int.Parse(SimId)); + + public bool IsHandleCreated => IsAttached; + public bool IsDisposed { get; private set; } + + // ---------- 事件 ---------- + + public event EventHandler? Click; + public event EventHandler? DoubleClick; + public event MouseEventHandler? MouseClick; + public event MouseEventHandler? MouseDown; + public event MouseEventHandler? MouseUp; + public event EventHandler? MouseEnter; + public event EventHandler? MouseLeave; + public event EventHandler? TextChanged; + public event EventHandler? VisibleChanged; + public event EventHandler? EnabledChanged; + public event EventHandler? GotFocus; + public event EventHandler? LostFocus; + public event EventHandler? Enter; + public event EventHandler? Leave; + + protected virtual void OnClick(EventArgs e) => Click?.Invoke(this, e); + protected virtual void OnDoubleClick(EventArgs e) => DoubleClick?.Invoke(this, e); + protected virtual void OnMouseClick(MouseEventArgs e) => MouseClick?.Invoke(this, e); + protected virtual void OnMouseDown(MouseEventArgs e) => MouseDown?.Invoke(this, e); + protected virtual void OnMouseUp(MouseEventArgs e) => MouseUp?.Invoke(this, e); + protected virtual void OnMouseEnter(EventArgs e) => MouseEnter?.Invoke(this, e); + protected virtual void OnMouseLeave(EventArgs e) => MouseLeave?.Invoke(this, e); + protected virtual void OnTextChanged(EventArgs e) => TextChanged?.Invoke(this, e); + protected virtual void OnVisibleChanged(EventArgs e) => VisibleChanged?.Invoke(this, e); + protected virtual void OnEnabledChanged(EventArgs e) => EnabledChanged?.Invoke(this, e); + protected virtual void OnGotFocus(EventArgs e) => GotFocus?.Invoke(this, e); + protected virtual void OnLostFocus(EventArgs e) => LostFocus?.Invoke(this, e); + protected virtual void OnEnter(EventArgs e) => Enter?.Invoke(this, e); + protected virtual void OnLeave(EventArgs e) => Leave?.Invoke(this, e); + + /// 平台内部使用:在界面线程上处理浏览器上报的用户操作 + [EditorBrowsable(EditorBrowsableState.Never)] + public void PerformUserAction(UserAction action) + { + if (!Enabled || !Visible) return; + OnUserAction(action); + } + + /// 按真实 WinForms 的顺序,把一个用户操作翻译成事件 + protected virtual void OnUserAction(UserAction action) + { + switch (action.Action) + { + case "click": + var args = new MouseEventArgs(MouseButtons.Left, 1, action.X, action.Y, 0); + OnClick(args); + OnMouseClick(args); + break; + case "mouseDown": + OnMouseDown(new MouseEventArgs(ToMouseButtons(action.Button), 1, action.X, action.Y, 0)); + break; + case "mouseUp": + OnMouseUp(new MouseEventArgs(ToMouseButtons(action.Button), 1, action.X, action.Y, 0)); + break; + } + } + + private static MouseButtons ToMouseButtons(string button) => button == "right" ? MouseButtons.Right : MouseButtons.Left; + + // ---------- 方法 ---------- + + public void Show() => Visible = true; + public void Hide() => Visible = false; + public bool Focus() => true; + public void Select() { } + public void BringToFront() { } + public void SendToBack() { } + public void Refresh() { } + public void Invalidate() { } + public void Update() { } + public void SuspendLayout() { } + public void ResumeLayout() { } + public void ResumeLayout(bool performLayout) { } + public void PerformLayout() { } + + public bool InvokeRequired => !SimHost.Ui.IsUiThread; + + public object? Invoke(Delegate method) => Invoke(method, Array.Empty()); + + public object? Invoke(Delegate method, params object?[]? args) + { + object? result = null; + SimHost.Ui.Send(() => result = method.DynamicInvoke(args)); + return result; + } + + public void Invoke(Action method) => SimHost.Ui.Send(method); + + public IAsyncResult BeginInvoke(Delegate method) => BeginInvoke(method, Array.Empty()); + + public IAsyncResult BeginInvoke(Delegate method, params object?[]? args) + { + var result = new SimpleAsyncResult(); + SimHost.Ui.Post(() => + { + try { result.Value = method.DynamicInvoke(args); } + finally { result.Complete(); } + }); + return result; + } + + public object? EndInvoke(IAsyncResult asyncResult) + { + var result = (SimpleAsyncResult)asyncResult; + result.AsyncWaitHandle.WaitOne(); + return result.Value; + } + + protected override void Dispose(bool disposing) + { + if (disposing && !IsDisposed) + { + IsDisposed = true; + _parent?.Controls.Remove(this); + } + base.Dispose(disposing); + } + + // ---------- 属性变化与快照 ---------- + + /// 修改字段并在需要时通知浏览器;返回值表示是否真的改变 + protected bool SetProp(ref T field, T value, string prop) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + EnsureThreadAccess(); + field = value; + NotifyChanged(prop, value); + return true; + } + + protected void NotifyChanged(string prop, object? value) + { + if (IsAttached) SimHost.QueueChange(this, prop, value); + } + + protected void EnsureThreadAccess() + { + if (CheckForIllegalCrossThreadCalls && IsAttached && !SimHost.Ui.IsUiThread) + throw new InvalidOperationException($"线程间操作无效: 从不是创建控件“{Name}”的线程访问它。"); + } + + /// 平台内部使用:生成发给浏览器的控件树 + [EditorBrowsable(EditorBrowsableState.Never)] + public JsonObject Snapshot() + { + var props = new JsonObject(); + WriteProps(props); + var children = new JsonArray(); + foreach (var child in Controls) children.Add(child.Snapshot()); + return new JsonObject + { + ["id"] = SimId, + ["type"] = ClientType, + ["name"] = Name, + ["props"] = props, + ["children"] = children, + }; + } + + /// 写入浏览器渲染需要的属性;子类追加自己的属性 + protected virtual void WriteProps(JsonObject props) + { + props["Text"] = Text; + props["Location"] = PropValue.From(_location); + props["Size"] = PropValue.From(_size); + props["Visible"] = _visible; + props["Enabled"] = _enabled; + props["BackColor"] = PropValue.ColorCss(_backColor); + props["ForeColor"] = PropValue.ColorCss(_foreColor); + props["Font"] = PropValue.From(_font); + props["AutoSize"] = _autoSize; + props["Dock"] = _dock.ToString(); + } + + /// 浏览器按这个类型名选择渲染方式。学生自己继承的控件按最近的仿真库基类渲染。 + private string ClientType + { + get + { + for (var type = GetType(); type != null; type = type.BaseType) + { + var attribute = type.GetCustomAttribute(inherit: false); + if (attribute != null) return attribute.Name; + if (type.Assembly == typeof(Control).Assembly) return type.Name; + } + return nameof(Control); + } + } + + private sealed class SimpleAsyncResult : IAsyncResult + { + private readonly System.Threading.ManualResetEvent _done = new(false); + public object? Value { get; set; } + public object? AsyncState => null; + public System.Threading.WaitHandle AsyncWaitHandle => _done; + public bool CompletedSynchronously => false; + public bool IsCompleted { get; private set; } + + public void Complete() + { + IsCompleted = true; + _done.Set(); + } + } + + public class ControlCollection : IEnumerable + { + private readonly List _items = new(); + + public ControlCollection(Control owner) => Owner = owner; + + public Control Owner { get; } + public int Count => _items.Count; + public Control this[int index] => _items[index]; + public Control? this[string key] => _items.Find(c => string.Equals(c.Name, key, StringComparison.OrdinalIgnoreCase)); + + public virtual void Add(Control? value) + { + if (value == null || value.Parent == Owner) return; + value.Parent?.Controls.Remove(value); + _items.Add(value); + value.SetParentInternal(Owner); + if (Owner.IsAttached) + { + SimHost.Send(new JsonObject + { + ["type"] = "controlAdded", + ["parentId"] = Owner.SimId, + ["index"] = _items.Count - 1, + ["node"] = value.Snapshot(), + }); + } + } + + public void AddRange(Control[] controls) + { + foreach (var control in controls) Add(control); + } + + public void Remove(Control? value) + { + if (value == null || !_items.Remove(value)) return; + var wasAttached = Owner.IsAttached; + value.SetParentInternal(null); + if (wasAttached) SimHost.Send(new JsonObject { ["type"] = "controlRemoved", ["id"] = value.SimId }); + } + + public void RemoveAt(int index) => Remove(_items[index]); + + public void Clear() + { + foreach (var control in _items.ToArray()) Remove(control); + } + + public bool Contains(Control? control) => control != null && _items.Contains(control); + public int IndexOf(Control? control) => control == null ? -1 : _items.IndexOf(control); + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} + +/// 仿真库内部使用:指定浏览器端的渲染类型名(教材控件库的控件用完整类名) +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class ClientTypeAttribute : Attribute +{ + public ClientTypeAttribute(string name) => Name = name; + + public string Name { get; } +} diff --git a/runtime/Sim.WinForms/Forms/Enums.cs b/runtime/Sim.WinForms/Forms/Enums.cs new file mode 100644 index 0000000..7dd9d38 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Enums.cs @@ -0,0 +1,100 @@ +// WinForms 中设计器代码和教材代码会用到的枚举与结构体,取值与 .NET Framework 一致。 +using System; + +namespace System.Windows.Forms; + +public enum AutoScaleMode { None, Font, Dpi, Inherit } + +public enum FormBorderStyle { None, FixedSingle, Fixed3D, FixedDialog, Sizable, FixedToolWindow, SizableToolWindow } + +public enum FormStartPosition { Manual, CenterScreen, WindowsDefaultLocation, WindowsDefaultBounds, CenterParent } + +public enum FormWindowState { Normal, Minimized, Maximized } + +public enum DialogResult { None, OK, Cancel, Abort, Retry, Ignore, Yes, No, TryAgain = 10, Continue = 11 } + +public enum MessageBoxButtons { OK, OKCancel, AbortRetryIgnore, YesNoCancel, YesNo, RetryCancel, CancelTryContinue } + +public enum MessageBoxIcon +{ + None = 0, + Hand = 16, + Stop = 16, + Error = 16, + Question = 32, + Exclamation = 48, + Warning = 48, + Asterisk = 64, + Information = 64, +} + +public enum MessageBoxDefaultButton { Button1 = 0, Button2 = 256, Button3 = 512, Button4 = 768 } + +public enum FlatStyle { Flat, Popup, Standard, System } + +public enum BorderStyle { None, FixedSingle, Fixed3D } + +public enum ScrollBars { None, Horizontal, Vertical, Both } + +public enum ComboBoxStyle { Simple, DropDown, DropDownList } + +public enum DockStyle { None, Top, Bottom, Left, Right, Fill } + +[Flags] +public enum AnchorStyles { None = 0, Top = 1, Bottom = 2, Left = 4, Right = 8 } + +public enum ImeMode { Inherit = -1, NoControl = 0, On = 1, Off = 2, Disable = 3 } + +public enum RightToLeft { No, Yes, Inherit } + +[Flags] +public enum MouseButtons { None = 0, Left = 0x100000, Right = 0x200000, Middle = 0x400000, XButton1 = 0x800000, XButton2 = 0x1000000 } + +public enum CloseReason { None, WindowsShutDown, MdiFormClosing, UserClosing, TaskManagerClosing, FormOwnerClosing, ApplicationExitCall } + +public struct Padding : IEquatable +{ + public static readonly Padding Empty = new(0); + + public Padding(int all) : this(all, all, all, all) { } + + public Padding(int left, int top, int right, int bottom) + { + Left = left; + Top = top; + Right = right; + Bottom = bottom; + } + + public int Left { get; set; } + public int Top { get; set; } + public int Right { get; set; } + public int Bottom { get; set; } + public int All + { + get => Left == Top && Top == Right && Right == Bottom ? Left : -1; + set => Left = Top = Right = Bottom = value; + } + public int Horizontal => Left + Right; + public int Vertical => Top + Bottom; + + public bool Equals(Padding other) => Left == other.Left && Top == other.Top && Right == other.Right && Bottom == other.Bottom; + public override bool Equals(object? obj) => obj is Padding other && Equals(other); + public override int GetHashCode() => HashCode.Combine(Left, Top, Right, Bottom); + public static bool operator ==(Padding a, Padding b) => a.Equals(b); + public static bool operator !=(Padding a, Padding b) => !a.Equals(b); + public override string ToString() => $"{{Left={Left},Top={Top},Right={Right},Bottom={Bottom}}}"; +} + +/// 窗口句柄接口;MessageBox.Show(owner, ...) 的 owner 参数类型 +public interface IWin32Window +{ + IntPtr Handle { get; } +} + +public interface IButtonControl +{ + DialogResult DialogResult { get; set; } + void NotifyDefault(bool value); + void PerformClick(); +} diff --git a/runtime/Sim.WinForms/Forms/EventArgs.cs b/runtime/Sim.WinForms/Forms/EventArgs.cs new file mode 100644 index 0000000..fa91bbf --- /dev/null +++ b/runtime/Sim.WinForms/Forms/EventArgs.cs @@ -0,0 +1,43 @@ +using System; +using System.ComponentModel; + +namespace System.Windows.Forms; + +public class MouseEventArgs : EventArgs +{ + public MouseEventArgs(MouseButtons button, int clicks, int x, int y, int delta) + { + Button = button; + Clicks = clicks; + X = x; + Y = y; + Delta = delta; + } + + public MouseButtons Button { get; } + public int Clicks { get; } + public int X { get; } + public int Y { get; } + public int Delta { get; } + public System.Drawing.Point Location => new(X, Y); +} + +public delegate void MouseEventHandler(object? sender, MouseEventArgs e); + +public class FormClosingEventArgs : CancelEventArgs +{ + public FormClosingEventArgs(CloseReason closeReason, bool cancel) : base(cancel) => CloseReason = closeReason; + + public CloseReason CloseReason { get; } +} + +public delegate void FormClosingEventHandler(object? sender, FormClosingEventArgs e); + +public class FormClosedEventArgs : EventArgs +{ + public FormClosedEventArgs(CloseReason closeReason) => CloseReason = closeReason; + + public CloseReason CloseReason { get; } +} + +public delegate void FormClosedEventHandler(object? sender, FormClosedEventArgs e); diff --git a/runtime/Sim.WinForms/Forms/Form.cs b/runtime/Sim.WinForms/Forms/Form.cs new file mode 100644 index 0000000..05b8162 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Form.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using System.Drawing; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +/// +/// 窗体。Show() 时先触发 Load,再把整个控件树发给浏览器;Close() 依次触发 FormClosing、FormClosed。 +/// +public class Form : ContainerControl +{ + // Windows 10 下可调边框窗体的标题栏和边框尺寸,Size = ClientSize + 边框 + private static readonly Size NonClientSize = new(16, 39); + + private Size _clientSize = new(284, 261); + private FormBorderStyle _formBorderStyle = FormBorderStyle.Sizable; + private bool _maximizeBox = true; + private bool _minimizeBox = true; + private bool _controlBox = true; + private bool _loaded; + private bool _closed; + private bool _modal; + private DialogResult _dialogResult; + + public Form() + { + base.Visible = false; + } + + internal bool IsOpenInBrowser { get; private set; } + + public override Size ClientSize + { + get => _clientSize; + set => SetProp(ref _clientSize, value, nameof(ClientSize)); + } + + public new Size Size + { + get => _clientSize + NonClientSize; + set => ClientSize = value - NonClientSize; + } + + public FormBorderStyle FormBorderStyle + { + get => _formBorderStyle; + set => SetProp(ref _formBorderStyle, value, nameof(FormBorderStyle)); + } + + public bool MaximizeBox + { + get => _maximizeBox; + set => SetProp(ref _maximizeBox, value, nameof(MaximizeBox)); + } + + public bool MinimizeBox + { + get => _minimizeBox; + set => SetProp(ref _minimizeBox, value, nameof(MinimizeBox)); + } + + public bool ControlBox + { + get => _controlBox; + set => SetProp(ref _controlBox, value, nameof(ControlBox)); + } + + public FormStartPosition StartPosition { get; set; } = FormStartPosition.WindowsDefaultLocation; + public FormWindowState WindowState { get; set; } + public bool ShowInTaskbar { get; set; } = true; + public bool TopMost { get; set; } + public bool KeyPreview { get; set; } + public double Opacity { get; set; } = 1; + public IButtonControl? AcceptButton { get; set; } + public IButtonControl? CancelButton { get; set; } + public Form? Owner { get; set; } + public bool Modal => _modal; + + public DialogResult DialogResult + { + get => _dialogResult; + set + { + _dialogResult = value; + // 模态窗体设置 DialogResult 后自动关闭 + if (_modal && value != DialogResult.None) Close(); + } + } + + public event EventHandler? Load; + public event EventHandler? Shown; + public event EventHandler? Activated; + public event FormClosingEventHandler? FormClosing; + public event FormClosedEventHandler? FormClosed; + + protected virtual void OnLoad(EventArgs e) => Load?.Invoke(this, e); + protected virtual void OnShown(EventArgs e) => Shown?.Invoke(this, e); + protected virtual void OnActivated(EventArgs e) => Activated?.Invoke(this, e); + protected virtual void OnFormClosing(FormClosingEventArgs e) => FormClosing?.Invoke(this, e); + protected virtual void OnFormClosed(FormClosedEventArgs e) => FormClosed?.Invoke(this, e); + + public new void Show() + { + if (_closed) throw new ObjectDisposedException(Name, "无法访问已释放的对象。"); + if (IsOpenInBrowser) + { + Visible = true; + return; + } + SimHost.Ui.Send(() => Open(modal: false)); + } + + public void Show(IWin32Window? owner) => Show(); + + public DialogResult ShowDialog() => ShowDialog(null); + + public DialogResult ShowDialog(IWin32Window? owner) + { + if (IsOpenInBrowser && Visible) throw new InvalidOperationException("窗体已可见,不能显示为模式对话框。"); + // 模态窗体关闭后不释放,可以再次 ShowDialog(Load 只在第一次触发) + _closed = false; + _dialogResult = DialogResult.None; + _modal = true; + Open(modal: true); + // 嵌套消息循环:等到窗体关闭(或被隐藏)后 ShowDialog 才返回 + SimHost.Ui.RunUntil(() => _closed || !Visible); + _modal = false; + return _dialogResult == DialogResult.None ? DialogResult.Cancel : _dialogResult; + } + + private void Open(bool modal) + { + if (!_loaded) + { + _loaded = true; + OnLoad(EventArgs.Empty); + if (_closed) return; // Load 里调用了 Close() + RaiseUserControlLoad(this); + } + base.Visible = true; + IsOpenInBrowser = true; + Application.OpenFormsList.Add(this); + SimHost.Send(new JsonObject { ["type"] = "formShown", ["form"] = Snapshot(), ["modal"] = modal }); + OnShown(EventArgs.Empty); + OnActivated(EventArgs.Empty); + } + + private static void RaiseUserControlLoad(Control parent) + { + foreach (var child in parent.Controls) + { + if (child is UserControl userControl) userControl.RaiseLoadOnce(); + RaiseUserControlLoad(child); + } + } + + public void Close() => CloseCore(CloseReason.UserClosing); + + internal void CloseCore(CloseReason reason) + { + if (_closed) return; + if (!IsOpenInBrowser) + { + _closed = true; + return; + } + var closing = new FormClosingEventArgs(reason, false); + OnFormClosing(closing); + if (closing.Cancel) return; + _closed = true; + if (_modal && _dialogResult == DialogResult.None) _dialogResult = DialogResult.Cancel; + OnFormClosed(new FormClosedEventArgs(reason)); + IsOpenInBrowser = false; + Application.OpenFormsList.Remove(this); + SimHost.Send(new JsonObject { ["type"] = "formClosed", ["formId"] = SimId }); + SimHost.Ui.Wake(); + if (!_modal) Dispose(); + } + + internal bool IsClosed => _closed; + + public void Activate() { } + + public void CenterToScreen() { } + + protected override void OnUserAction(UserAction action) + { + if (action.Action == "close") + { + CloseCore(CloseReason.UserClosing); + return; + } + base.OnUserAction(action); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["Size"] = PropValue.From(Size); + props["ClientSize"] = PropValue.From(_clientSize); + props["FormBorderStyle"] = _formBorderStyle.ToString(); + props["MaximizeBox"] = _maximizeBox; + props["MinimizeBox"] = _minimizeBox; + props["ControlBox"] = _controlBox; + props["StartPosition"] = StartPosition.ToString(); + } +} diff --git a/runtime/Sim.WinForms/Forms/Label.cs b/runtime/Sim.WinForms/Forms/Label.cs new file mode 100644 index 0000000..d717e59 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Label.cs @@ -0,0 +1,34 @@ +using System.Drawing; +using System.Text.Json.Nodes; + +namespace System.Windows.Forms; + +public class Label : Control +{ + private BorderStyle _borderStyle; + private ContentAlignment _textAlign = ContentAlignment.TopLeft; + + protected override Size DefaultSize => new(100, 23); + + public BorderStyle BorderStyle + { + get => _borderStyle; + set => SetProp(ref _borderStyle, value, nameof(BorderStyle)); + } + + public ContentAlignment TextAlign + { + get => _textAlign; + set => SetProp(ref _textAlign, value, nameof(TextAlign)); + } + + public bool UseMnemonic { get; set; } = true; + public FlatStyle FlatStyle { get; set; } = FlatStyle.Standard; + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["BorderStyle"] = _borderStyle.ToString(); + props["TextAlign"] = _textAlign.ToString(); + } +} diff --git a/runtime/Sim.WinForms/Forms/MessageBox.cs b/runtime/Sim.WinForms/Forms/MessageBox.cs new file mode 100644 index 0000000..47c100e --- /dev/null +++ b/runtime/Sim.WinForms/Forms/MessageBox.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +/// 在浏览器里弹出对话框,阻塞到用户点击按钮后返回结果(期间界面线程照常处理 Timer 等消息) +public static class MessageBox +{ + public static DialogResult Show(string? text) => Show(text, ""); + + public static DialogResult Show(string? text, string? caption) => + Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.None); + + public static DialogResult Show(string? text, string? caption, MessageBoxButtons buttons) => + Show(text, caption, buttons, MessageBoxIcon.None); + + public static DialogResult Show(string? text, string? caption, MessageBoxButtons buttons, MessageBoxIcon icon) => + ShowCore(text, caption, buttons, icon); + + public static DialogResult Show(string? text, string? caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) => + ShowCore(text, caption, buttons, icon); + + public static DialogResult Show(IWin32Window? owner, string? text) => Show(text); + + public static DialogResult Show(IWin32Window? owner, string? text, string? caption) => Show(text, caption); + + public static DialogResult Show(IWin32Window? owner, string? text, string? caption, MessageBoxButtons buttons) => + Show(text, caption, buttons); + + public static DialogResult Show(IWin32Window? owner, string? text, string? caption, MessageBoxButtons buttons, MessageBoxIcon icon) => + Show(text, caption, buttons, icon); + + private static DialogResult ShowCore(string? text, string? caption, MessageBoxButtons buttons, MessageBoxIcon icon) + { + var reply = SimHost.Request(new JsonObject + { + ["type"] = "messageBox", + ["text"] = text ?? "", + ["caption"] = caption ?? "", + ["buttons"] = buttons.ToString(), + ["icon"] = icon.ToString(), + }); + return Enum.TryParse((string?)reply, out var result) ? result : DialogResult.Cancel; + } +} diff --git a/runtime/Sim.WinForms/Forms/TextBox.cs b/runtime/Sim.WinForms/Forms/TextBox.cs new file mode 100644 index 0000000..5826fc1 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/TextBox.cs @@ -0,0 +1,108 @@ +using System.Drawing; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +public abstract class TextBoxBase : Control +{ + private bool _multiline; + private bool _readOnly; + private BorderStyle _borderStyle = BorderStyle.Fixed3D; + + public bool Multiline + { + get => _multiline; + set => SetProp(ref _multiline, value, nameof(Multiline)); + } + + public bool ReadOnly + { + get => _readOnly; + set => SetProp(ref _readOnly, value, nameof(ReadOnly)); + } + + public BorderStyle BorderStyle + { + get => _borderStyle; + set => SetProp(ref _borderStyle, value, nameof(BorderStyle)); + } + + public int MaxLength { get; set; } = 32767; + public bool WordWrap { get; set; } = true; + public bool HideSelection { get; set; } = true; + public bool Modified { get; set; } + public int SelectionStart { get; set; } + public int SelectionLength { get; set; } + public int TextLength => Text.Length; + + public string[] Lines + { + get => Text.Replace("\r\n", "\n").Split('\n'); + set => Text = string.Join("\r\n", value); + } + + public void AppendText(string text) => Text += text; + public void Clear() => Text = ""; + public void SelectAll() { } + public void Select(int start, int length) { } + public void ScrollToCaret() { } + + protected override void OnUserAction(UserAction action) + { + if (action.Action == "text" && !ReadOnly) + { + Modified = true; + SetTextFromUser(action.Value ?? ""); + return; + } + base.OnUserAction(action); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["Multiline"] = _multiline; + props["ReadOnly"] = _readOnly; + props["BorderStyle"] = _borderStyle.ToString(); + } +} + +public class TextBox : TextBoxBase +{ + private char _passwordChar; + private bool _useSystemPasswordChar; + private ScrollBars _scrollBars; + + protected override Size DefaultSize => new(100, 21); + + public char PasswordChar + { + get => _passwordChar; + set => SetProp(ref _passwordChar, value, nameof(PasswordChar)); + } + + public bool UseSystemPasswordChar + { + get => _useSystemPasswordChar; + set => SetProp(ref _useSystemPasswordChar, value, nameof(UseSystemPasswordChar)); + } + + public ScrollBars ScrollBars + { + get => _scrollBars; + set => SetProp(ref _scrollBars, value, nameof(ScrollBars)); + } + + public HorizontalAlignment TextAlign { get; set; } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["PasswordChar"] = PropValue.From(_passwordChar); + props["UseSystemPasswordChar"] = _useSystemPasswordChar; + props["ScrollBars"] = _scrollBars.ToString(); + } +} + +public enum HorizontalAlignment { Left, Right, Center } diff --git a/runtime/Sim.WinForms/Forms/Threading.cs b/runtime/Sim.WinForms/Forms/Threading.cs new file mode 100644 index 0000000..eb15b1d --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Threading.cs @@ -0,0 +1,16 @@ +using System.Threading; +using Sim.Host; + +namespace System.Windows.Forms; + +public delegate void MethodInvoker(); + +/// 界面线程的同步上下文:Send 同步执行,Post 排队执行(教材项目 8 用它从后台线程更新界面) +public sealed class WindowsFormsSynchronizationContext : SynchronizationContext +{ + public override void Send(SendOrPostCallback d, object? state) => SimHost.Ui.Send(() => d(state)); + + public override void Post(SendOrPostCallback d, object? state) => SimHost.Ui.Post(() => d(state)); + + public override SynchronizationContext CreateCopy() => new WindowsFormsSynchronizationContext(); +} diff --git a/runtime/Sim.WinForms/Forms/Timer.cs b/runtime/Sim.WinForms/Forms/Timer.cs new file mode 100644 index 0000000..07e3e27 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/Timer.cs @@ -0,0 +1,78 @@ +using System.ComponentModel; +using Sim.Host; + +namespace System.Windows.Forms; + +/// 窗体定时器:Tick 事件总是在界面线程上触发,上一次 Tick 处理完之前不会重复排队 +public class Timer : Component +{ + private System.Threading.Timer? _timer; + private int _interval = 100; + private bool _enabled; + private int _tickQueued; + + public Timer() { } + + public Timer(IContainer container) : this() => container.Add(this); + + public int Interval + { + get => _interval; + set + { + if (value < 1) throw new ArgumentOutOfRangeException(nameof(value), $"值“{value}”对于 Interval 无效。Interval 必须大于 0。"); + _interval = value; + if (_enabled) _timer?.Change(value, value); + } + } + + public bool Enabled + { + get => _enabled; + set + { + if (_enabled == value) return; + _enabled = value; + if (value) + { + _timer ??= new System.Threading.Timer(_ => QueueTick()); + _timer.Change(_interval, _interval); + } + else + { + _timer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite); + } + } + } + + public object? Tag { get; set; } + + public event EventHandler? Tick; + + public void Start() => Enabled = true; + + public void Stop() => Enabled = false; + + protected virtual void OnTick(EventArgs e) => Tick?.Invoke(this, e); + + private void QueueTick() + { + if (System.Threading.Interlocked.Exchange(ref _tickQueued, 1) == 1) return; + SimHost.Ui.Post(() => + { + _tickQueued = 0; + if (_enabled) OnTick(EventArgs.Empty); + }); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _enabled = false; + _timer?.Dispose(); + _timer = null; + } + base.Dispose(disposing); + } +} diff --git a/runtime/Sim.WinForms/Host/PropValue.cs b/runtime/Sim.WinForms/Host/PropValue.cs new file mode 100644 index 0000000..f8dd9a5 --- /dev/null +++ b/runtime/Sim.WinForms/Host/PropValue.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections; +using System.Drawing; +using System.Globalization; +using System.Text.Json.Nodes; +using System.Windows.Forms; + +namespace Sim.Host; + +/// 把控件属性值转换成发给浏览器的 JSON(格式见 web/src/winforms/format.ts) +public static class PropValue +{ + public static JsonNode? From(object? value) => value switch + { + null => null, + string s => JsonValueOf(s), + bool b => JsonValueOf(b), + char c => JsonValueOf(c == '\0' ? "" : c.ToString()), + int or long or short or byte => JsonValueOf(Convert.ToInt64(value, CultureInfo.InvariantCulture)), + float or double or decimal => JsonValueOf(Convert.ToDouble(value, CultureInfo.InvariantCulture)), + Enum e => JsonValueOf(e.ToString()), + Point p => new JsonObject { ["x"] = p.X, ["y"] = p.Y }, + Size s => new JsonObject { ["width"] = s.Width, ["height"] = s.Height }, + Color c => ColorCss(c), + Font f => new JsonObject + { + ["family"] = f.Name, + ["size"] = f.SizeInPoints, + ["bold"] = f.Bold, + ["italic"] = f.Italic, + ["underline"] = f.Underline, + ["strikeout"] = f.Strikeout, + }, + Padding p => new JsonObject { ["left"] = p.Left, ["top"] = p.Top, ["right"] = p.Right, ["bottom"] = p.Bottom }, + IEnumerable list => ListOf(list), + _ => JsonValueOf(value.ToString() ?? ""), + }; + + /// 未设置的颜色(Color.Empty)返回 null,表示沿用父控件的颜色 + public static JsonNode? ColorCss(Color color) + { + if (color.IsEmpty) return null; + if (color.A == 0) return "transparent"; + if (color.A < 255) + return $"rgba({color.R},{color.G},{color.B},{(color.A / 255.0).ToString("0.###", CultureInfo.InvariantCulture)})"; + return $"#{color.R:x2}{color.G:x2}{color.B:x2}"; + } + + private static JsonArray ListOf(IEnumerable items) + { + var array = new JsonArray(); + foreach (var item in items) array.Add(From(item is string or null ? item : item.ToString())); + return array; + } + + private static JsonNode JsonValueOf(T value) => JsonValue.Create(value)!; +} diff --git a/runtime/Sim.WinForms/Host/Protocol.cs b/runtime/Sim.WinForms/Host/Protocol.cs new file mode 100644 index 0000000..89ddb5a --- /dev/null +++ b/runtime/Sim.WinForms/Host/Protocol.cs @@ -0,0 +1,26 @@ +// 运行器与服务器之间的消息格式,对应 shared/protocol.ts,两边修改时要同步。 +// 运行器 → 服务器的消息在各处用 JsonObject 直接构造;这里只集中定义从服务器收到的动作。 +using System.Text.Json.Nodes; + +namespace Sim.Host; + +/// 浏览器上报的用户操作(shared/protocol.ts 中的 UserAction) +public sealed class UserAction +{ + public required string Action { get; init; } + public string? Value { get; init; } + public int Index { get; init; } + public string Button { get; init; } = "left"; + public int X { get; init; } + public int Y { get; init; } + + public static UserAction FromJson(JsonObject json) => new() + { + Action = (string?)json["action"] ?? "", + Value = (string?)json["value"], + Index = (int?)json["index"] ?? -1, + Button = (string?)json["button"] ?? "left", + X = (int?)json["x"] ?? 0, + Y = (int?)json["y"] ?? 0, + }; +} diff --git a/runtime/Sim.WinForms/Host/SimHost.cs b/runtime/Sim.WinForms/Host/SimHost.cs new file mode 100644 index 0000000..805aeb7 --- /dev/null +++ b/runtime/Sim.WinForms/Host/SimHost.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Windows.Forms; + +namespace Sim.Host; + +/// +/// 运行器的全局状态:向服务器发消息、收集控件属性变化、登记控件、等待浏览器的应答。 +/// +public static class SimHost +{ + // 中文原样输出,不转义成 \uXXXX + private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + private static readonly object WriteLock = new(); + private static readonly List PendingChanges = new(); + private static readonly ConcurrentDictionary ControlsById = new(); + private static readonly ConcurrentDictionary Requests = new(); + private static TextWriter _output = TextWriter.Null; + private static int _nextControlId; + private static int _nextRequestId; + + public static UiDispatcher Ui { get; } = new(); + + /// 学生程序所在的程序集,用于筛选异常堆栈中属于学生代码的部分 + public static Assembly? StudentAssembly { get; set; } + + /// 运行器启动时调用:协议消息写到真正的标准输出(学生的 Console 输出另行转发) + public static void Initialize(Stream stdout) + { + _output = new StreamWriter(stdout, new UTF8Encoding(false)) { AutoFlush = true }; + } + + // ---------- 发送消息 ---------- + + public static void Send(JsonObject message) + { + lock (WriteLock) + { + FlushChangesLocked(); + _output.WriteLine(message.ToJsonString(JsonOptions)); + } + } + + /// 记录一个已显示控件的属性变化;在界面线程上会合并到本轮消息处理结束时一起发送 + public static void QueueChange(Control control, string prop, object? value) + { + var change = new JsonObject { ["id"] = control.SimId, ["prop"] = prop, ["value"] = PropValue.From(value) }; + lock (WriteLock) + { + PendingChanges.Add(change); + if (!Ui.IsUiThread) FlushChangesLocked(); + } + } + + public static void FlushChanges() + { + lock (WriteLock) FlushChangesLocked(); + } + + private static void FlushChangesLocked() + { + if (PendingChanges.Count == 0) return; + var changes = new JsonArray(); + foreach (var change in PendingChanges) changes.Add(change); + PendingChanges.Clear(); + _output.WriteLine(new JsonObject { ["type"] = "patch", ["changes"] = changes }.ToJsonString(JsonOptions)); + } + + public static void SendConsole(string text) => Send(new JsonObject { ["type"] = "console", ["text"] = text }); + + public static void SendDevice(string device, string action, object? data) => Send(new JsonObject + { + ["type"] = "device", + ["device"] = device, + ["action"] = action, + ["data"] = PropValue.From(data), + }); + + // ---------- 异常 ---------- + + public static void ReportException(Exception ex) + { + while (ex is TargetInvocationException { InnerException: not null } wrapper) ex = wrapper.InnerException!; + Send(new JsonObject + { + ["type"] = "exception", + ["message"] = $"{ex.GetType().FullName}: {ex.Message}", + ["detail"] = StudentStackTrace(ex), + }); + } + + /// 只保留学生代码的堆栈行,去掉仿真库内部的调用 + private static string StudentStackTrace(Exception ex) + { + var lines = (ex.StackTrace ?? "").Split('\n').Select(l => l.TrimEnd()); + var studentLines = lines.Where(l => l.Contains(".cs:") && !l.Contains("/runtime/Sim.")).ToList(); + return string.Join("\n", studentLines.Count > 0 ? studentLines : lines); + } + + // ---------- 控件登记 ---------- + + public static string NewControlId() => Interlocked.Increment(ref _nextControlId).ToString(); + + public static void Register(Control control) => ControlsById[control.SimId] = control; + + public static Control? FindControl(string id) => ControlsById.TryGetValue(id, out var control) ? control : null; + + // ---------- 请求与应答(如 MessageBox 等待用户点按钮) ---------- + + /// + /// 向浏览器发出请求并阻塞等待应答。在界面线程上调用时会继续处理消息队列(和真实的模态对话框一样,Timer 照常触发)。 + /// + public static JsonNode? Request(JsonObject message) + { + var id = Interlocked.Increment(ref _nextRequestId); + var pending = new PendingRequest(); + Requests[id] = pending; + message["requestId"] = id; + Send(message); + if (Ui.IsUiThread) Ui.RunUntil(() => pending.Done.IsSet); + else pending.Done.Wait(); + Requests.TryRemove(id, out _); + return pending.Result; + } + + public static void CompleteRequest(int id, JsonNode? result) + { + if (!Requests.TryGetValue(id, out var pending)) return; + pending.Result = result; + pending.Done.Set(); + Ui.Wake(); + } + + private sealed class PendingRequest + { + public ManualResetEventSlim Done { get; } = new(); + public JsonNode? Result { get; set; } + } +} diff --git a/runtime/Sim.WinForms/Host/UiDispatcher.cs b/runtime/Sim.WinForms/Host/UiDispatcher.cs new file mode 100644 index 0000000..4f04c02 --- /dev/null +++ b/runtime/Sim.WinForms/Host/UiDispatcher.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace Sim.Host; + +/// +/// 模拟 WinForms 的界面线程消息循环:所有用户操作、Timer.Tick、Invoke 都排队到界面线程上依次执行。 +/// +public sealed class UiDispatcher +{ + private readonly BlockingCollection _queue = new(); + + public Thread? UiThread { get; private set; } + + public bool IsUiThread => Thread.CurrentThread == UiThread; + + /// 由运行器在启动学生程序的线程上调用一次 + public void BindToCurrentThread() => UiThread = Thread.CurrentThread; + + public void Post(Action action) => _queue.Add(action); + + /// 在界面线程上同步执行(Control.Invoke、SynchronizationContext.Send) + public void Send(Action action) + { + if (IsUiThread) + { + action(); + return; + } + using var done = new ManualResetEventSlim(); + Exception? error = null; + Post(() => + { + try { action(); } + catch (Exception ex) { error = ex; } + finally { done.Set(); } + }); + done.Wait(); + if (error != null) throw new System.Reflection.TargetInvocationException(error); + } + + /// + /// 运行消息循环,直到 为真。可以嵌套: + /// Application.Run、ShowDialog、MessageBox.Show 都会在这里等待,期间 Timer 等照常执行。 + /// + public void RunUntil(Func done) + { + while (!done()) + { + if (_queue.TryTake(out var action, 50)) Execute(action); + } + } + + /// 处理队列里已有的消息后返回(Application.DoEvents) + public void DoEvents() + { + while (_queue.TryTake(out var action)) Execute(action); + } + + /// 唤醒等待中的消息循环,让它重新检查结束条件 + public void Wake() => Post(static () => { }); + + private static void Execute(Action action) + { + try + { + action(); + } + catch (Exception ex) + { + // 真实 WinForms 会弹出"未经处理的异常"对话框,点"继续"后程序接着运行,这里同样继续 + SimHost.ReportException(ex); + } + finally + { + SimHost.FlushChanges(); + } + } +} diff --git a/runtime/Sim.WinForms/Ports/SerialPort.cs b/runtime/Sim.WinForms/Ports/SerialPort.cs new file mode 100644 index 0000000..bd03cb0 --- /dev/null +++ b/runtime/Sim.WinForms/Ports/SerialPort.cs @@ -0,0 +1,38 @@ +using System.ComponentModel; + +namespace System.IO.Ports; + +/// +/// 学生代码只用 SerialPort.GetPortNames() 填下拉框,设备读写都走 DeviceCtrlHelper。 +/// 这里返回实训台上的虚拟串口;直接读写串口不在教学范围内,调用时给出提示。 +/// +public class SerialPort : Component +{ + /// COM1 是电脑自带串口,COM2–COM5 对应实训台上的 4017、4150、ZigBee 协调器和 LED 屏 + private static readonly string[] VirtualPorts = { "COM1", "COM2", "COM3", "COM4", "COM5" }; + + public SerialPort() { } + + public SerialPort(string portName) => PortName = portName; + + public SerialPort(string portName, int baudRate) : this(portName) => BaudRate = baudRate; + + public static string[] GetPortNames() => (string[])VirtualPorts.Clone(); + + public string PortName { get; set; } = "COM1"; + public int BaudRate { get; set; } = 9600; + public int DataBits { get; set; } = 8; + public int ReadTimeout { get; set; } = -1; + public int WriteTimeout { get; set; } = -1; + public bool IsOpen { get; private set; } + + public void Open() => throw NotSupported(); + public void Close() => IsOpen = false; + public void Write(string text) => throw NotSupported(); + public void WriteLine(string text) => throw NotSupported(); + public string ReadLine() => throw NotSupported(); + public string ReadExisting() => throw NotSupported(); + + private static NotSupportedException NotSupported() => + new("仿真平台不支持直接读写串口,请使用 DeviceCtrlHelper 操作实训台设备。"); +} diff --git a/runtime/Sim.WinForms/Sim.WinForms.csproj b/runtime/Sim.WinForms/Sim.WinForms.csproj new file mode 100644 index 0000000..10e2b3c --- /dev/null +++ b/runtime/Sim.WinForms/Sim.WinForms.csproj @@ -0,0 +1,10 @@ + + + + Sim.WinForms + System.Windows.Forms + + $(NoWarn);CS1591;CS8618 + + diff --git a/samples/p2-t1-LED显示屏/Form1.Designer.cs b/samples/p2-t1-LED显示屏/Form1.Designer.cs new file mode 100644 index 0000000..7b787b6 --- /dev/null +++ b/samples/p2-t1-LED显示屏/Form1.Designer.cs @@ -0,0 +1,144 @@ +namespace LED显示屏 +{ + 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.led = new WinFormsDeviceControlLibrary.LED(); + this.groupBox1 = new System.Windows.Forms.GroupBox(); + this.com_Port = new System.Windows.Forms.ComboBox(); + this.btn_Send = new System.Windows.Forms.Button(); + this.label2 = new System.Windows.Forms.Label(); + this.txt_SendText = new System.Windows.Forms.TextBox(); + this.btn_Open = new System.Windows.Forms.Button(); + this.label1 = new System.Windows.Forms.Label(); + this.groupBox1.SuspendLayout(); + this.SuspendLayout(); + // + // led + // + this.led.Location = new System.Drawing.Point(12, 12); + this.led.Name = "led"; + this.led.Size = new System.Drawing.Size(750, 121); + this.led.TabIndex = 0; + // + // groupBox1 + // + this.groupBox1.Controls.Add(this.com_Port); + this.groupBox1.Controls.Add(this.btn_Send); + this.groupBox1.Controls.Add(this.label2); + this.groupBox1.Controls.Add(this.txt_SendText); + this.groupBox1.Controls.Add(this.btn_Open); + this.groupBox1.Controls.Add(this.label1); + this.groupBox1.Location = new System.Drawing.Point(347, 154); + this.groupBox1.Name = "groupBox1"; + this.groupBox1.Size = new System.Drawing.Size(415, 142); + this.groupBox1.TabIndex = 1; + this.groupBox1.TabStop = false; + this.groupBox1.Text = "LED控制"; + // + // com_Port + // + this.com_Port.FormattingEnabled = true; + this.com_Port.Location = new System.Drawing.Point(101, 24); + this.com_Port.Name = "com_Port"; + this.com_Port.Size = new System.Drawing.Size(173, 20); + this.com_Port.TabIndex = 6; + this.com_Port.Click += new System.EventHandler(this.com_Port_Click); + // + // btn_Send + // + this.btn_Send.Location = new System.Drawing.Point(301, 91); + this.btn_Send.Name = "btn_Send"; + this.btn_Send.Size = new System.Drawing.Size(75, 23); + this.btn_Send.TabIndex = 5; + this.btn_Send.Text = "推 送"; + this.btn_Send.UseVisualStyleBackColor = true; + this.btn_Send.Click += new System.EventHandler(this.btn_Send_Click); + // + // label2 + // + this.label2.AutoSize = true; + this.label2.Location = new System.Drawing.Point(16, 67); + this.label2.Name = "label2"; + this.label2.Size = new System.Drawing.Size(71, 12); + this.label2.TabIndex = 4; + this.label2.Text = "推送LED信息"; + // + // txt_SendText + // + this.txt_SendText.Location = new System.Drawing.Point(101, 64); + this.txt_SendText.Name = "txt_SendText"; + this.txt_SendText.Size = new System.Drawing.Size(286, 21); + this.txt_SendText.TabIndex = 3; + // + // btn_Open + // + this.btn_Open.Location = new System.Drawing.Point(301, 20); + this.btn_Open.Name = "btn_Open"; + this.btn_Open.Size = new System.Drawing.Size(86, 23); + this.btn_Open.TabIndex = 2; + 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(16, 23); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(71, 12); + this.label1.TabIndex = 0; + this.label1.Text = "串 口"; + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(774, 308); + this.Controls.Add(this.groupBox1); + this.Controls.Add(this.led); + this.Name = "Form1"; + this.Text = "LED显示屏"; + this.groupBox1.ResumeLayout(false); + this.groupBox1.PerformLayout(); + this.ResumeLayout(false); + + } + + #endregion + + private WinFormsDeviceControlLibrary.LED led; + private System.Windows.Forms.GroupBox groupBox1; + private System.Windows.Forms.Button btn_Send; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.TextBox txt_SendText; + private System.Windows.Forms.Button btn_Open; + private System.Windows.Forms.Label label1; + private System.Windows.Forms.ComboBox com_Port; + } +} diff --git a/samples/p2-t1-LED显示屏/Form1.cs b/samples/p2-t1-LED显示屏/Form1.cs new file mode 100644 index 0000000..a4121d4 --- /dev/null +++ b/samples/p2-t1-LED显示屏/Form1.cs @@ -0,0 +1,55 @@ +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.Ports;//调用系统串口资源类所在的命名空间 +using DeviceCtrlLibrary;//调用外部设备控制资源类所在的命名空间 +using WinFormsDeviceControlLibrary;//引用外部窗体类所在的命名空间 + +namespace LED显示屏 +{ + public partial class Form1 : Form + { + public Form1() + { + InitializeComponent(); + } + + private void btn_Open_Click(object sender, EventArgs e) + { + //在com_Port下拉列表中选中的项,作为当前串口 + DeviceCtrlHelper.ledHelper.OpenLedCom(com_Port.Text); + } + + private void btn_Send_Click(object sender, EventArgs e) + { + // 读txt_SendText控件的内容,保存在字符串变量res中 + string res = DeviceCtrlHelper.ledHelper.SendLedString(txt_SendText.Text); + //判断字符串res是否为空 + if (res.IndexOf("Error") >= 0) + { + MessageBox.Show(res); //出错提示 + } + else + { + led.setLedText(res);//发送 + } + } + + private void com_Port_Click(object sender, EventArgs e) + { + //清空列表 + com_Port.Items.Clear(); + //加载所有可用的串口号到列表中 + foreach (string item in SerialPort.GetPortNames()) + { + com_Port.Items.Add(item); + } + } + } +} diff --git a/samples/p2-t1-LED显示屏/Program.cs b/samples/p2-t1-LED显示屏/Program.cs new file mode 100644 index 0000000..daaa11c --- /dev/null +++ b/samples/p2-t1-LED显示屏/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace LED显示屏 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/samples/p2-t1-LED显示屏/meta.json b/samples/p2-t1-LED显示屏/meta.json new file mode 100644 index 0000000..8b14043 --- /dev/null +++ b/samples/p2-t1-LED显示屏/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目2 任务1 LED显示屏的使用", + "source": "教材书 p23–27,按书上代码整理(控件名以书上代码为准)" +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..09d5627 --- /dev/null +++ b/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "@iot/server", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts" + }, + "dependencies": { + "@iot/shared": "workspace:*", + "hono": "^4.7.0" + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "typescript": "^5.8.0" + } +} diff --git a/server/src/compile-service.ts b/server/src/compile-service.ts new file mode 100644 index 0000000..99cb6ff --- /dev/null +++ b/server/src/compile-service.ts @@ -0,0 +1,72 @@ +import type { Subprocess } from 'bun' +import type { Diagnostic } from '@iot/shared' +import { RUNNER_DLL, readLines } from './runner-process' + +export interface CompileResult { + ok: boolean + diagnostics: Diagnostic[] + ms: number +} + +type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string } + +/** + * 常驻编译服务:全服务器共用一个 C# 编译进程(Roslyn 只加载一次),按请求编号对应回复。 + * 进程意外退出时,挂起的请求全部失败,下一次请求自动重启。 + */ +class CompileService { + private proc?: Subprocess<'pipe', 'pipe', 'inherit'> + private ready?: Promise + private nextId = 1 + private readonly pending = new Map void; reject: (e: Error) => void }>() + + /** 编译工程目录;成功时程序集写到同一目录 */ + async compile(dir: string): Promise { + await this.start() + const id = this.nextId++ + const result = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })) + this.proc!.stdin.write(`${JSON.stringify({ id, dir })}\n`) + this.proc!.stdin.flush() + return result + } + + stop() { + this.proc?.kill() + } + + /** 服务器启动时调用,提前完成编译器的加载和预热 */ + start() { + if (this.ready) return this.ready + const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'compile-server'], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'inherit', + env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' }, + }) + this.proc = proc + this.ready = new Promise((resolveReady) => { + void readLines(proc.stdout, (line) => { + const message = JSON.parse(line) as ({ type: 'ready' } | Reply) & { id?: number } + if (message.type === 'ready') { + resolveReady() + return + } + const waiter = this.pending.get(message.id!) + if (!waiter) return + this.pending.delete(message.id!) + if (message.type === 'compiled') waiter.resolve(message) + else waiter.reject(new Error(message.message)) + }) + }) + proc.exited.then((code) => { + if (this.proc !== proc) return + this.proc = undefined + this.ready = undefined + for (const waiter of this.pending.values()) waiter.reject(new Error(`编译服务意外退出(${code})`)) + this.pending.clear() + }) + return this.ready + } +} + +export const compileService = new CompileService() diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..90ae7d1 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,65 @@ +import { Hono } from 'hono' +import type { ClientMessage, ServerMessage } from '@iot/shared' +import { compileService } from './compile-service' +import { cleanupStaleRuns, RunSession } from './run-session' +import { listSamples, loadSample } from './samples' + +await cleanupStaleRuns() + +const app = new Hono() + +app.get('/api/samples', async (c) => c.json(await listSamples())) + +app.get('/api/samples/:id', async (c) => { + const files = await loadSample(c.req.param('id')) + return files ? c.json(files) : c.notFound() +}) + +interface SocketData { + session?: RunSession +} + +const server = Bun.serve({ + port: Number(process.env.PORT ?? 3000), + fetch(req, server) { + if (new URL(req.url).pathname === '/ws') { + if (server.upgrade(req, { data: {} })) return + return new Response('WebSocket 升级失败', { status: 400 }) + } + return app.fetch(req) + }, + websocket: { + data: {} as SocketData, + open(ws) { + ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message))) + }, + async message(ws, raw) { + let message: ClientMessage + try { + message = JSON.parse(String(raw)) + } catch { + return + } + const session = ws.data.session + if (!session) return + if (message.type === 'run') { + try { + await session.start(message.files) + } catch (error) { + ws.send(JSON.stringify({ type: 'serverError', message: String(error) } satisfies ServerMessage)) + } + } else if (message.type === 'stop') { + await session.stop() + } else { + session.input(message) + } + }, + async close(ws) { + await ws.data.session?.dispose() + }, + }, +}) + +console.log(`服务器已启动:http://localhost:${server.port}`) +await compileService.start() +console.log('C# 编译服务已就绪') diff --git a/server/src/run-session.ts b/server/src/run-session.ts new file mode 100644 index 0000000..6abf0a9 --- /dev/null +++ b/server/src/run-session.ts @@ -0,0 +1,129 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { dirname, join, normalize, resolve } from 'node:path' +import type { Subprocess } from 'bun' +import type { RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared' +import { compileService } from './compile-service' +import { ROOT, RUNNER_DLL, readLines } from './runner-process' + +const RUNS_DIR = join(ROOT, '.runs') + +/** 服务器启动时清掉上次异常退出遗留的运行目录 */ +export async function cleanupStaleRuns() { + await rm(RUNS_DIR, { recursive: true, force: true }) +} + +/** 一次运行:写出学生工程 → 编译服务编译 → 启动运行进程,在运行进程和浏览器之间转发消息 */ +export class RunSession { + private proc?: Subprocess<'pipe', 'pipe', 'pipe'> + private dir?: string + /** 每次启动、停止都递增,用来丢弃已经过时的编译结果 */ + private generation = 0 + /** 同一会话的多次"运行"排队依次执行,避免两次启动交错 */ + private queue = Promise.resolve() + + constructor(private readonly send: (message: ServerMessage) => void) {} + + start(files: SourceFile[]) { + const next = this.queue.then(() => this.startNow(files)) + this.queue = next.catch(() => {}) + return next + } + + private async startNow(files: SourceFile[]) { + await this.dispose() + const generation = ++this.generation + await mkdir(RUNS_DIR, { recursive: true }) + const dir = await mkdtemp(join(RUNS_DIR, 'run-')) + for (const file of files) { + const target = safeJoin(dir, file.path) + await mkdir(dirname(target), { recursive: true }) + await Bun.write(target, file.content) + } + + this.send({ type: 'status', state: 'compiling' }) + const compiled = await compileService.compile(dir) + // 编译期间又点了一次"运行"或"停止":这次结果作废 + if (generation !== this.generation) { + await rm(dir, { recursive: true, force: true }) + return + } + this.dir = dir + this.send({ type: 'compiled', ...compiled }) + if (!compiled.ok) { + this.send({ type: 'status', state: 'stopped' }) + await this.cleanup() + return + } + + const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', dir], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' }, + }) + this.proc = proc + + void readLines(proc.stdout, (line) => { + let message: RunnerMessage + try { + message = JSON.parse(line) + } catch { + this.send({ type: 'console', text: `${line}\n` }) + return + } + if (message.type === 'started') this.send({ type: 'status', state: 'running' }) + this.send(message) + }) + void readLines(proc.stderr, (line) => this.send({ type: 'serverError', message: line })) + + proc.exited.then(async (code) => { + if (this.proc !== proc) return + this.proc = undefined + this.send({ type: 'exited', code }) + this.send({ type: 'status', state: 'stopped' }) + await this.cleanup() + }) + } + + input(message: RunnerInput) { + if (!this.proc) return + this.proc.stdin.write(`${JSON.stringify(message)}\n`) + this.proc.stdin.flush() + } + + async dispose() { + this.generation++ + await this.killProc() + await this.cleanup() + } + + /** 浏览器点"停止":直接结束进程(不依赖学生程序还能响应) */ + async stop() { + this.generation++ + await this.killProc() + this.send({ type: 'status', state: 'stopped' }) + await this.cleanup() + } + + private async killProc() { + const proc = this.proc + this.proc = undefined + if (!proc) return + proc.kill() + await proc.exited + } + + private async cleanup() { + if (!this.dir) return + const dir = this.dir + this.dir = undefined + await rm(dir, { recursive: true, force: true }) + } +} + +/** 拼接路径并拒绝跳出工程目录的文件名 */ +function safeJoin(base: string, path: string) { + const target = resolve(base, normalize(path)) + if (!target.startsWith(`${base}/`)) throw new Error(`非法文件路径:${path}`) + return target +} diff --git a/server/src/runner-process.ts b/server/src/runner-process.ts new file mode 100644 index 0000000..0389deb --- /dev/null +++ b/server/src/runner-process.ts @@ -0,0 +1,21 @@ +import { resolve } from 'node:path' + +export const ROOT = resolve(import.meta.dir, '../..') +export const RUNNER_DLL = resolve(ROOT, 'runtime/Sim.Runner/bin/Debug/net10.0/Sim.Runner.dll') + +/** 逐行读取子进程输出 */ +export async function readLines(stream: ReadableStream, onLine: (line: string) => void) { + const decoder = new TextDecoder() + let buffer = '' + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }) + let index = buffer.indexOf('\n') + while (index >= 0) { + const line = buffer.slice(0, index).trimEnd() + buffer = buffer.slice(index + 1) + if (line) onLine(line) + index = buffer.indexOf('\n') + } + } + if (buffer.trim()) onLine(buffer.trim()) +} diff --git a/server/src/samples.ts b/server/src/samples.ts new file mode 100644 index 0000000..d59d51c --- /dev/null +++ b/server/src/samples.ts @@ -0,0 +1,33 @@ +import { readdir } from 'node:fs/promises' +import { join, relative, resolve } from 'node:path' +import type { SourceFile } from '@iot/shared' + +const SAMPLES_DIR = resolve(import.meta.dir, '../../samples') + +export interface SampleInfo { + id: string + title: string +} + +/** 示例工程:samples/ 下每个子目录是一个工程,meta.json 里写标题 */ +export async function listSamples(): Promise { + const entries = await readdir(SAMPLES_DIR, { withFileTypes: true }) + const samples: SampleInfo[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + const meta = await Bun.file(join(SAMPLES_DIR, entry.name, 'meta.json')).json() + samples.push({ id: entry.name, title: meta.title }) + } + return samples.sort((a, b) => a.id.localeCompare(b.id)) +} + +export async function loadSample(id: string): Promise { + const dir = resolve(SAMPLES_DIR, id) + if (!dir.startsWith(`${SAMPLES_DIR}/`)) return null + const files: SourceFile[] = [] + const glob = new Bun.Glob('**/*.cs') + for await (const path of glob.scan({ cwd: dir })) { + files.push({ path: relative(dir, join(dir, path)), content: await Bun.file(join(dir, path)).text() }) + } + return files.length > 0 ? files.sort((a, b) => a.path.localeCompare(b.path)) : null +} diff --git a/server/test/load.ts b/server/test/load.ts new file mode 100644 index 0000000..c0da608 --- /dev/null +++ b/server/test/load.ts @@ -0,0 +1,102 @@ +// 并发压力测试:同时运行 N 个学生程序(教材项目 2 任务 1),测量内存、启动时间和操作往返延迟。 +// 用法:bun test/load.ts [N],默认 100。运行前先编译运行时:bun run build:runtime +import { readFileSync } from 'node:fs' +import { cp, mkdir, rm } from 'node:fs/promises' +import { resolve } from 'node:path' +import type { ControlNode, RunnerMessage } from '@iot/shared' +import { compileService } from '../src/compile-service' +import { ROOT, RUNNER_DLL, readLines } from '../src/runner-process' + +const COUNT = Number(process.argv[2] ?? 100) +const dir = resolve(ROOT, '.runs/load-test') + +await rm(dir, { recursive: true, force: true }) +await mkdir(dir, { recursive: true }) +await cp(resolve(ROOT, 'samples/p2-t1-LED显示屏'), dir, { recursive: true }) +const compiled = await compileService.compile(dir) +compileService.stop() +if (!compiled.ok) throw new Error('示例编译失败') + +interface Instance { + proc: Bun.Subprocess<'pipe', 'pipe', 'inherit'> + form?: ControlNode + onMessage?: (m: RunnerMessage) => void +} + +function findId(node: ControlNode, name: string): string | undefined { + if (node.name === name) return node.id + for (const child of node.children) { + const id = findId(child, name) + if (id) return id + } +} + +function send(instance: Instance, message: object) { + instance.proc.stdin.write(`${JSON.stringify(message)}\n`) + instance.proc.stdin.flush() +} + +/** 读取 /proc 里的内存数据(MB)。Rss 会把多个进程共享的 .NET 运行库重复计算,Pss 按进程数平摊,合计值更接近真实占用 */ +function memoryMb(pid: number, field: 'Rss' | 'Pss') { + const rollup = readFileSync(`/proc/${pid}/smaps_rollup`, 'utf8') + return Number(new RegExp(`^${field}:\\s+(\\d+)`, 'm').exec(rollup)![1]) / 1024 +} + +console.log(`启动 ${COUNT} 个学生程序…`) +const startAt = performance.now() +const instances: Instance[] = [] +const shown: Promise[] = [] +for (let i = 0; i < COUNT; i++) { + const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', dir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }) + const instance: Instance = { proc } + instances.push(instance) + shown.push( + new Promise((resolveShown) => { + void readLines(proc.stdout, (line) => { + const message = JSON.parse(line) as RunnerMessage + if (message.type === 'formShown') { + instance.form = message.form + resolveShown() + } + instance.onMessage?.(message) + }) + }), + ) +} +await Promise.all(shown) +console.log(`全部窗体显示完成:${Math.round(performance.now() - startAt)} ms`) + +await Bun.sleep(3000) +for (const field of ['Rss', 'Pss'] as const) { + const values = instances.map((i) => memoryMb(i.proc.pid, field)) + const total = values.reduce((a, b) => a + b, 0) + console.log(`内存 ${field}:合计 ${Math.round(total)} MB,平均 ${Math.round(total / COUNT)} MB`) +} + +// 每个程序:选 COM5 → 打开 → 输入文字 → 推送,测量从发出"推送"到收到 LED 变化的往返时间 +const latencies = await Promise.all( + instances.map(async (instance) => { + const form = instance.form! + const id = (name: string) => findId(form, name)! + send(instance, { type: 'action', id: id('com_Port'), action: 'dropDown' }) + send(instance, { type: 'action', id: id('com_Port'), action: 'select', index: 4 }) + send(instance, { type: 'action', id: id('btn_Open'), action: 'click' }) + send(instance, { type: 'action', id: id('txt_SendText'), action: 'text', value: '压力测试' }) + await Bun.sleep(200) + const led = id('led') + const sentAt = performance.now() + const done = new Promise((resolveDone) => { + instance.onMessage = (m) => { + if (m.type === 'patch' && m.changes.some((c) => c.id === led && c.prop === 'LedText')) resolveDone(performance.now() - sentAt) + } + }) + send(instance, { type: 'action', id: id('btn_Send'), action: 'click' }) + return done + }), +) +latencies.sort((a, b) => a - b) +const pct = (p: number) => Math.round(latencies[Math.min(latencies.length - 1, Math.floor((latencies.length * p) / 100))]) +console.log(`操作往返(${COUNT} 个程序同时点击):中位数 ${pct(50)} ms,P95 ${pct(95)} ms,最大 ${pct(100)} ms`) + +for (const instance of instances) instance.proc.kill() +await rm(dir, { recursive: true, force: true }) diff --git a/server/test/runner.test.ts b/server/test/runner.test.ts new file mode 100644 index 0000000..7086f30 --- /dev/null +++ b/server/test/runner.test.ts @@ -0,0 +1,149 @@ +// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材项目 2 任务 1 的行为。 +// 运行前先编译运行时:bun run build:runtime +import { afterAll, afterEach, expect, test } from 'bun:test' +import { resolve } from 'node:path' +import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared' +import { compileService } from '../src/compile-service' +import { ROOT, RUNNER_DLL } from '../src/runner-process' + +class Runner { + readonly messages: RunnerMessage[] = [] + private proc?: Bun.Subprocess<'pipe', 'pipe', 'inherit'> + private waiters: { match: (m: RunnerMessage) => boolean; resolve: (m: RunnerMessage) => void }[] = [] + + /** 与服务器相同的流程:先由编译服务编译,成功后启动运行进程 */ + static async start(projectDir: string) { + const runner = new Runner() + const compiled = await compileService.compile(projectDir) + runner.receive({ type: 'compiled', ...compiled }) + if (compiled.ok) { + runner.proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', projectDir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }) + void runner.read() + } + return runner + } + + private receive(message: RunnerMessage) { + this.messages.push(message) + this.waiters = this.waiters.filter((w) => { + if (!w.match(message)) return true + w.resolve(message) + return false + }) + } + + private async read() { + const decoder = new TextDecoder() + let buffer = '' + for await (const chunk of this.proc!.stdout) { + buffer += decoder.decode(chunk, { stream: true }) + let index: number + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index) + buffer = buffer.slice(index + 1) + if (!line.trim()) continue + this.receive(JSON.parse(line) as RunnerMessage) + } + } + } + + /** 等待下一条满足条件的消息(已经收到的也算) */ + waitFor( + type: T, + match: (m: Extract) => boolean = () => true, + timeoutMs = 15000, + ): Promise> { + const isMatch = (m: RunnerMessage) => m.type === type && match(m as Extract) + const existing = this.messages.find(isMatch) + if (existing) return Promise.resolve(existing as Extract) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`等待 ${type} 超时`)), timeoutMs) + this.waiters.push({ + match: isMatch, + resolve: (m) => { + clearTimeout(timer) + resolve(m as Extract) + }, + }) + }) + } + + send(message: RunnerInput) { + this.proc!.stdin.write(`${JSON.stringify(message)}\n`) + this.proc!.stdin.flush() + } + + kill() { + this.proc?.kill() + } +} + +function findByName(node: ControlNode, name: string): ControlNode { + if (node.name === name) return node + for (const child of node.children) { + const found = findByName(child, name) + if (found.name === name) return found + } + return { id: '', type: '', name: '', props: {}, children: [] } +} + +let runner: Runner | undefined +afterEach(() => runner?.kill()) +afterAll(() => compileService.stop()) + +test('项目2 任务1:选串口、打开、推送文字到 LED 屏', async () => { + // 编译产物写在工程目录里,所以复制到临时目录再编译 + const dir = resolve(ROOT, '.runs/test-led') + const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏') + for (const name of ['Program.cs', 'Form1.cs', 'Form1.Designer.cs']) await Bun.write(`${dir}/${name}`, Bun.file(`${sample}/${name}`)) + runner = await Runner.start(dir) + const compiled = await runner.waitFor('compiled') + expect(compiled.diagnostics).toEqual([]) + const { form } = await runner.waitFor('formShown') + expect(form.props.Text).toBe('LED显示屏') + + const id = (name: string) => findByName(form, name).id + const comPort = id('com_Port') + + // 先推送、不打开串口:教材约定返回 "Error:发送失败",学生代码弹出 MessageBox + runner.send({ type: 'action', id: id('txt_SendText'), action: 'text', value: '欢迎学习物联网' }) + runner.send({ type: 'action', id: id('btn_Send'), action: 'click' }) + const box = await runner.waitFor('messageBox') + expect(box.text).toBe('Error:发送失败') + runner.send({ type: 'reply', requestId: box.requestId, result: 'OK' }) + + // 点下拉框:Click 事件里填充串口列表 + runner.send({ type: 'action', id: comPort, action: 'dropDown' }) + const items = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === comPort && c.prop === 'Items')) + const ports = items.changes.findLast((c) => c.id === comPort && c.prop === 'Items')!.value as string[] + expect(ports).toContain('COM5') + + runner.send({ type: 'action', id: comPort, action: 'select', index: ports.indexOf('COM5') }) + runner.send({ type: 'action', id: id('btn_Open'), action: 'click' }) + runner.send({ type: 'action', id: id('btn_Send'), action: 'click' }) + + const device = await runner.waitFor('device', (m) => m.device === 'led') + expect(device.data).toBe('欢迎学习物联网') + const led = id('led') + const ledPatch = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === led && c.prop === 'LedText')) + expect(ledPatch.changes.find((c) => c.id === led)!.value).toBe('欢迎学习物联网') + + // 关闭主窗体:程序结束 + runner.send({ type: 'action', id: form.id, action: 'close' }) + await runner.waitFor('formClosed') +}, 30000) + +test('编译错误:书上常见的 True 大写给出中文提示', async () => { + const dir = resolve(ROOT, '.runs/test-true') + const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏') + for (const name of ['Program.cs', 'Form1.Designer.cs']) await Bun.write(`${dir}/${name}`, Bun.file(`${sample}/${name}`)) + const form1 = await Bun.file(`${sample}/Form1.cs`).text() + await Bun.write(`${dir}/Form1.cs`, form1.replace('private void btn_Open_Click(object sender, EventArgs e)\n {', 'private void btn_Open_Click(object sender, EventArgs e)\n {\n bool IsOpen = True;')) + + runner = await Runner.start(dir) + const compiled = await runner.waitFor('compiled') + expect(compiled.ok).toBe(false) + const error = compiled.diagnostics.find((d) => d.code === 'CS0103') + expect(error?.message).toContain('True') + expect(error?.hint).toContain('小写') +}, 30000) diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..46a8675 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "types": ["bun"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "test", "../shared"] +} diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..f5007ce --- /dev/null +++ b/shared/package.json @@ -0,0 +1,7 @@ +{ + "name": "@iot/shared", + "private": true, + "type": "module", + "main": "protocol.ts", + "types": "protocol.ts" +} diff --git a/shared/protocol.ts b/shared/protocol.ts new file mode 100644 index 0000000..6caad8b --- /dev/null +++ b/shared/protocol.ts @@ -0,0 +1,79 @@ +// 浏览器、Bun 服务器、C# 运行器之间的消息协议。 +// 运行器 ↔ 服务器:标准输入输出,每行一条 JSON。 +// 服务器 ↔ 浏览器:WebSocket,每帧一条 JSON。 +// C# 端的对应定义在 runtime/Sim.WinForms/Host/(Protocol.cs、各控件的 WriteProps),两边修改时要同步。 + +/** 学生工程里的一个源文件 */ +export interface SourceFile { + path: string + content: string +} + +export interface Diagnostic { + file: string + line: number + column: number + severity: 'error' | 'warning' + code: string + message: string + /** 平台补充的中文提示,例如书上常见错误的说明 */ + hint?: string +} + +/** 控件树节点。props 里的键名使用 WinForms 属性名,如 Text、Location、BackColor */ +export interface ControlNode { + id: string + type: string + name: string + props: Record + children: ControlNode[] +} + +export interface PropChange { + id: string + prop: string + value: unknown +} + +/** 运行器发出的消息 */ +export type RunnerMessage = + | { type: 'compiled'; ok: boolean; diagnostics: Diagnostic[]; ms: number } + | { type: 'started'; ms: number } + | { type: 'formShown'; form: ControlNode; modal: boolean } + | { type: 'formClosed'; formId: string } + | { type: 'patch'; changes: PropChange[] } + | { type: 'controlAdded'; parentId: string; index: number; node: ControlNode } + | { type: 'controlRemoved'; id: string } + | { type: 'messageBox'; requestId: number; text: string; caption: string; buttons: string; icon: string } + | { type: 'device'; device: string; action: string; data: unknown } + | { type: 'console'; text: string } + | { type: 'exception'; message: string; detail: string } + | { type: 'exited'; code: number } + +/** + * 用户在浏览器里的操作。浏览器只描述"用户做了什么", + * 由运行器按真实 WinForms 的顺序决定触发哪些事件。 + */ +export type UserAction = + | { action: 'click' } + | { action: 'text'; value: string } + | { action: 'dropDown' } + | { action: 'select'; index: number } + | { action: 'mouseDown'; button: 'left' | 'right'; x: number; y: number } + | { action: 'mouseUp'; button: 'left' | 'right'; x: number; y: number } + | { action: 'close' } + +/** 发给运行器的消息 */ +export type RunnerInput = + | ({ type: 'action'; id: string } & UserAction) + | { type: 'reply'; requestId: number; result: unknown } + | { type: 'stop' } + +/** 浏览器发给服务器的消息 */ +export type ClientMessage = { type: 'run'; files: SourceFile[] } | RunnerInput + +/** 服务器发给浏览器的消息 */ +export type ServerMessage = + | RunnerMessage + | { type: 'status'; state: 'compiling' | 'running' | 'stopped' } + | { type: 'serverError'; message: string } diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e56436c --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + 物联网编程仿真平台 + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..6febd51 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "@iot/web", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --bun vite", + "build": "vue-tsc --noEmit && bun --bun vite build", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@iot/shared": "workspace:*", + "naive-ui": "^2.41.0", + "vue": "^3.5.0" + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "@vitejs/plugin-vue": "^6.0.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "vue-tsc": "^3.0.0" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..2bc6ddb --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,227 @@ + + +