From 5a9c948d43d1b3a6cb3ba77176bbf36f461454aa Mon Sep 17 00:00:00 2001 From: jkdevito Date: Wed, 30 Sep 2026 11:22:52 -0500 Subject: [PATCH 1/7] feat: add local README docs generation and reorganize README Add Update-ReadmeDocs.ps1, a VS Code task and an update-readme-docs skill to generate plugin docs locally. Remove the robot-docs CI caller. --- .github/scripts/Update-ReadmeDocs.ps1 | 127 +++++++++ .github/skills/update-readme-docs/SKILL.md | 27 ++ .../essentialsplugins-updatereadme-caller.yml | 14 - .vscode/tasks.json | 9 + README.md | 241 +++++++++++++++--- 5 files changed, 374 insertions(+), 44 deletions(-) create mode 100644 .github/scripts/Update-ReadmeDocs.ps1 create mode 100644 .github/skills/update-readme-docs/SKILL.md delete mode 100644 .github/workflows/essentialsplugins-updatereadme-caller.yml diff --git a/.github/scripts/Update-ReadmeDocs.ps1 b/.github/scripts/Update-ReadmeDocs.ps1 new file mode 100644 index 0000000..36157e3 --- /dev/null +++ b/.github/scripts/Update-ReadmeDocs.ps1 @@ -0,0 +1,127 @@ +<# +.SYNOPSIS + Regenerates the plugin documentation sections in README.md locally. +.DESCRIPTION + Runs the same metadata.py used by the PepperDash/workflow-templates update-readme + workflow, but against the local working tree so the result lands in your branch. + Sections live between / markers; add + inside a section to stop it from being regenerated. +.PARAMETER Ref + workflow-templates branch or tag to download metadata.py from. +.PARAMETER ScriptPath + Use a local metadata.py instead of downloading one. +#> +[CmdletBinding()] +param( + [string]$Ref = 'main', + [string]$ScriptPath +) + +$ErrorActionPreference = 'Stop' +$root = Resolve-Path (Join-Path $PSScriptRoot '..\..') + +$python = @('python3', 'python', 'py') | + ForEach-Object { Get-Command $_ -ErrorAction SilentlyContinue } | + Where-Object { $_.Source -notmatch 'WindowsApps' } | + Select-Object -First 1 +if (-not $python) { + [Console]::Error.WriteLine('readme-docs: Python 3 not found on PATH (https://www.python.org/downloads/)') + exit 1 +} + +if (-not $ScriptPath) { + $ScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) 'pd-readme-metadata.py' + $url = "https://raw.githubusercontent.com/PepperDash/workflow-templates/$Ref/.github/scripts/metadata.py" + Invoke-WebRequest -Uri $url -OutFile $ScriptPath -UseBasicParsing +} + +# metadata.py logs at DEBUG level; keep only INFO and above +& $python.Source $ScriptPath $root 2>&1 | + Where-Object { "$_" -notmatch '^DEBUG:' } | + ForEach-Object { Write-Host $_ } +if ($LASTEXITCODE -ne 0) { + [Console]::Error.WriteLine("readme-docs: metadata.py failed (exit $LASTEXITCODE)") + exit $LASTEXITCODE +} + +# Config Example: "type" = first TypeNames entry of the first *Factory.cs (by name); "uid" is unused and removed. +# Applied even to sections. +$utf8 = New-Object System.Text.UTF8Encoding($false) +$configType = $null +$factories = Get-ChildItem -Path (Join-Path $root 'src') -Filter '*Factory.cs' -Recurse | + Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' } | + Sort-Object Name +foreach ($file in $factories) { + $code = [regex]::Replace([System.IO.File]::ReadAllText($file.FullName, $utf8), '//.*', '') + $m = [regex]::Match($code, 'TypeNames\s*=\s*new\s+List\s*\(\s*\)\s*\{\s*"([^"]+)"') + if ($m.Success) { $configType = $m.Groups[1].Value; break } +} +$readmePath = Join-Path $root 'README.md' + +# Base Classes and Interfaces: types declared by the plugin's own device classes (not factories or join maps). +# Interfaces go in the generator's "Interfaces Implemented" section, retitled "Interfaces". Sections marked are left alone. +function Split-TopLevel([string]$list) { + $parts = @(); $depth = 0; $cur = '' + foreach ($ch in $list.ToCharArray()) { + if ($ch -eq '<') { $depth++ } elseif ($ch -eq '>') { $depth-- } + if ($ch -eq ',' -and $depth -eq 0) { $parts += $cur.Trim(); $cur = '' } else { $cur += $ch } + } + if ($cur.Trim()) { $parts += $cur.Trim() } + $parts +} +$classPattern = [regex]'(?m)^[ \t]*(?:(?:public|internal|abstract|sealed|static|partial)[ \t]+)*class[ \t]+(\w+)[ \t\r\n]*:([^{]+)\{' +$baseTypes = New-Object System.Collections.Generic.List[string] +$interfaceTypes = New-Object System.Collections.Generic.List[string] +$sources = Get-ChildItem -Path (Join-Path $root 'src') -Filter '*.cs' -Recurse | + Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' } | + Sort-Object Name +foreach ($file in $sources) { + $code = [System.IO.File]::ReadAllText($file.FullName, $utf8) + $code = [regex]::Replace([regex]::Replace($code, '(?s)/\*.*?\*/', ''), '//.*', '') + foreach ($m in $classPattern.Matches($code)) { + if ($m.Groups[1].Value -like '*Factory') { continue } + $bases = Split-TopLevel (($m.Groups[2].Value -split '\swhere\s')[0]) + if ($bases -contains 'JoinMapBaseAdvanced') { continue } + foreach ($b in $bases) { + if ($b -match '^I[A-Z]') { + if (-not $interfaceTypes.Contains($b)) { $interfaceTypes.Add($b) } + } + elseif (-not $baseTypes.Contains($b)) { $baseTypes.Add($b) } + } + } +} + +function Set-SectionBody([string]$text, [string]$name, [string]$body) { + $pattern = '(?s)()(.*?)()' + $m = [regex]::Match($text, $pattern) + if (-not $m.Success -or $m.Groups[2].Value -match '') { return $text } + $text.Substring(0, $m.Index) + $m.Groups[1].Value + $body + $m.Groups[3].Value + $text.Substring($m.Index + $m.Length) +} + +if (Test-Path $readmePath) { + $original = [System.IO.File]::ReadAllText($readmePath, $utf8) + $readme = $original + $nl = if ($readme.Contains("`r`n")) { "`r`n" } else { "`n" } + + $section = [regex]::Match($readme, '(?s).*?') + if ($section.Success) { + $fixed = [regex]::Replace($section.Value, '(?m)^[ \t]*"uid":\s*\d+,?[ \t]*\r?\n', '') + if ($configType) { + $fixed = ([regex]'"type":\s*"[^"]*"').Replace($fixed, "`"type`": `"$configType`"", 1) + } + $readme = $readme.Substring(0, $section.Index) + $fixed + $readme.Substring($section.Index + $section.Length) + } + + $listBody = { + param([string]$title, $items) + if (@($items).Count -eq 0) { return $nl } + $nl + "### $title" + $nl + $nl + ((@($items) | ForEach-Object { "- ``$_``" }) -join $nl) + $nl + } + $readme = Set-SectionBody $readme 'Base Classes' (& $listBody 'Base Classes' $baseTypes) + $readme = Set-SectionBody $readme 'Interfaces Implemented' (& $listBody 'Interfaces' $interfaceTypes) + + if ($readme -cne $original) { [System.IO.File]::WriteAllText($readmePath, $readme, $utf8) } +} + +git -C $root diff --stat -- README.md +Write-Host 'readme-docs: review the README.md diff, then commit it with your changes' diff --git a/.github/skills/update-readme-docs/SKILL.md b/.github/skills/update-readme-docs/SKILL.md new file mode 100644 index 0000000..685aef6 --- /dev/null +++ b/.github/skills/update-readme-docs/SKILL.md @@ -0,0 +1,27 @@ +--- +name: update-readme-docs +description: 'Update the plugin documentation sections in README.md (config example, supported types, join maps, feedbacks, methods). Use when: documenting a plugin, refreshing README after code changes, cleaning up generated README sections.' +--- + +# Update README plugin docs + +Regenerates the marker-delimited documentation sections in `README.md` on the current branch, then reviews them. + +## Procedure + +1. Run `pwsh -NoProfile -File .github/scripts/Update-ReadmeDocs.ps1` (Windows: `powershell -NoProfile -ExecutionPolicy Bypass -File ...`). Requires Python 3. Do not switch branches. +2. Read `git diff README.md` and compare each generated section against the source in `src/`: + - **Minimum Essentials Framework Versions**: collapse duplicates to one entry per distinct value and mark `` (the generator re-adds duplicates otherwise). + - **Supported Types**: must match every `TypeNames` entry in the `*Factory.cs` files. + - **Config Example**: `type` is set by the script to the first `TypeNames` entry of the first `*Factory.cs` (by file name); do not change it by hand. The script also removes the unused `uid` property. Must use the real config class(es), JSON property names and value types (nested objects such as `control` are emitted as `"SampleValue"`). Replace `SampleString`/`SampleValue`/`GeneratedKey` placeholders with realistic values, using the `` marker (see below). Take example values from the `` blocks in `MakeModelConfigObject.cs`. + - **Join Maps**: if empty, the generator could not find the join map file (it looks for `.cs`). Write the table by hand from the `JoinDataComplete` definitions and mark the section ``. + - **Base Classes / Interfaces**: the script lists the base classes (Base Classes) and interfaces (Interfaces) declared by the plugin's device classes, excluding factories and join maps. Verify against `src/`; do not edit by hand unless you add ``. Interfaces uses the generator's `Interfaces Implemented` markers and is empty when there are none. + - **Public Methods / Feedbacks**: remove noise (non-public API, base classes of factories, template-only members) by hand and mark ``; for a section that does not apply, keep the markers with only `` between them (deleting the markers does not work, the generator re-adds them). +3. Ensure a blank line precedes each `` marker and the file ends with a newline. +4. Do not edit anything outside the ``/`` blocks. +5. Report which sections were regenerated, hand-edited (now ``), or removed. Do not commit unless asked. + +## Rules + +- `` inside a section makes the generator leave it untouched on later runs. Use it only for sections you curated by hand, and say so in the summary. +- Never fabricate joins, feedbacks, or config properties; derive everything from `src/`. diff --git a/.github/workflows/essentialsplugins-updatereadme-caller.yml b/.github/workflows/essentialsplugins-updatereadme-caller.yml deleted file mode 100644 index 8f4bbe3..0000000 --- a/.github/workflows/essentialsplugins-updatereadme-caller.yml +++ /dev/null @@ -1,14 +0,0 @@ - -name: Generate README - -on: - push: - branches-ignore: - - 'robot-docs' - -jobs: - call-update-readme: - uses: PepperDash/workflow-templates/.github/workflows/update-readme.yml@main - with: - target-branch: ${{ github.ref_name }} - diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7ac839e..6bfcf2d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -26,6 +26,15 @@ "runOn": "folderOpen" }, "problemMatcher": [] + }, + { + "label": "Update README docs", + "type": "shell", + "command": "pwsh -NoProfile -File .github/scripts/Update-ReadmeDocs.ps1", + "windows": { + "command": "powershell -NoProfile -ExecutionPolicy Bypass -File .github/scripts/Update-ReadmeDocs.ps1" + }, + "problemMatcher": [] } ] } diff --git a/README.md b/README.md index 2f148af..769d830 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,86 @@ -![PepperDash Essentials Pluign Logo](/images/essentials-plugin-blue.png) +![PepperDash Essentials Plugin Logo](/images/essentials-plugin-blue.png) ![PepperDash Essentials](https://img.shields.io/badge/PepperDash%20Essentials-≥%20v2.42.4-blue) ![.NET](https://img.shields.io/badge/.NET%20Framework-4.7.2-512BD4) ![Crestron](https://img.shields.io/badge/Crestron-4--Series-lightgrey) ![License](https://img.shields.io/badge/license-MIT-green) -# Essentials Plugin Template (c) 2026 - -## License - -Provided under MIT license +# Essentials Plugin Template (c) 2025 ## Overview -Fork this repo when creating a new plugin for Essentials. For more information about plugins, refer to the Essentials Wiki [Plugins](https://pepperdash.github.io/Essentials/docs/Plugins.html) article. +Use this repository as the starting point for a new Essentials plugin. For more information about plugins, refer to the Essentials Wiki [Plugins](https://pepperdash.github.io/Essentials/docs/Plugins.html) article. This repo contains example classes for the three main categories of devices: -* `MakeModelDevice`: Used for most third party devices which require communication over a streaming mechanism such as a Com port, TCP/SSh/UDP socket, CEC, etc -* `MakeModelLogicDevice`: Used for devices that contain logic, but don't require any communication with third parties outside the program -* `MakeModelCrestronDevice`: Used for devices that represent a piece of Crestron hardware -There are matching factory classes for each of the three categories of devices. The `MakeModelConfigObject` should be used as a template and modified for any of the categories of device. Same goes for the `MakeModeleBridgeJoinMap`. +* `MakeModelDevice`: Used for most third party devices which require communication over a streaming mechanism such as a Com port, TCP/SSH/UDP socket, CEC, etc +* `MakeModelLogicDevice`: Used for devices that contain logic, but don't require any communication with third parties outside the program +* `MakeModelCrestronDevice`: Used for devices that represent a piece of Crestron hardware + +There are matching factory classes for each of the three categories of devices. `MakeModelConfigObject` and `MakeModelBridgeJoinMap` are templates that should be modified for whichever categories of device the plugin uses. This also illustrates how a plugin can contain multiple devices. -## Cloning Instructions +## Getting Started + +1. **Create your repository.** Fork this repository into your own GitHub space, then create a new repository using it as the template. +2. **Build.** Open `epi-make-model.4Series.sln` in Visual Studio or VS Code and build, or run `dotnet build` from the repo root. Dependencies restore automatically (see [Prerequisites](#prerequisites)). +3. **Rename and customize.** Work through the [customization checklist](#renaming-and-customizing-the-template). +4. **Set up releases.** Update `.releaserc.json` (see [Building, Packaging and Releasing](#building-packaging-and-releasing)). +5. **Document the plugin.** Generate the plugin documentation at the end of this README (see [README Docs](#readme-docs-generated-plugin-documentation)). + +## Prerequisites + +* [.NET SDK](https://dotnet.microsoft.com/download) (builds the project and provides `dotnet tool` for the git hooks) +* Visual Studio or VS Code +* The [Essentials](https://github.com/PepperDash/Essentials) libraries are referenced with a NuGet `PackageReference` (`PepperDashEssentials`) in `src/epi-make-model.4Series.csproj`. They are restored automatically by Visual Studio, `dotnet restore` or `dotnet build`; `nuget.exe` is not required. + +The git hooks and README docs tooling have their own requirements; see [Repository Automation](#repository-automation). + +## Renaming and Customizing the Template + +There is extensive inline documentation and examples in the source. In Visual Studio, the Task List lists every `TODO [ ]` item to complete. For renaming instructions in particular, see the XML `remarks` tags on the class definitions. + +Checklist: + +1. Rename the solution, project, namespace and classes to match the plugin. The template's class and file names are not fully consistent (for example, the join map class is `EssentialsPluginTemplateBridgeJoinMap` in `MakeModelBridgeJoinMap.cs`), so search for both `MakeModel` and `EssentialsPluginTemplate`. +2. Delete the device categories and factories the plugin does not need. +3. In each factory, set `MinimumEssentialsFrameworkVersion` and `TypeNames`. +4. Set the `PepperDashEssentials` package version in the csproj to `MinimumEssentialsFrameworkVersion` or higher (the build fails if it is lower; see [README Badges](#readme-badges)). +5. Modify `MakeModelConfigObject` and `MakeModelBridgeJoinMap` for the plugin's configuration and joins. +6. Update the package properties in the csproj (see [Package properties](#package-properties)). +7. Update `.releaserc.json` (see [Building, Packaging and Releasing](#building-packaging-and-releasing)). +8. Regenerate the README docs. -After forking this repository into your own GitHub space, you can create a new repository using this one as the template. Then you must install the necessary dependencies as indicated below. +## Building, Packaging and Releasing -## Dependencies +Building the project (Visual Studio, or `dotnet build`) produces two artifacts in `output/`: -The [Essentials](https://github.com/PepperDash/Essentials) libraries are required. They referenced via nuget. You must have nuget.exe installed and in the `PATH` environment variable to use the following command. Nuget.exe is available at [nuget.org](https://dist.nuget.org/win-x86-commandline/latest/nuget.exe). +* `..cplz` - the plugin, for loading on a processor +* `..nupkg` - the NuGet package, which includes `LICENSE.md` and this `README.md` -### Installing Dependencies +Because this README is packed into the NuGet package, keep it accurate for plugin users. -Dependencies will be automatically installed when +### Package properties -### Instructions for Renaming Solution and Files +To modify the name and other details of the package, edit the following properties in `src/epi-make-model.4Series.csproj`: -See the Task List in Visual Studio for a guide on how to start using the template. There is extensive inline documentation and examples as well. +1. `PackageId` - This is the name that will be used to pull the package from NuGet once it's published +2. `PackageProjectUrl` - This should match the URL for the plugin repo +3. `AssemblyTitle` - This is the DLL file name that will show on a processor when the plugin is loaded -For renaming instructions in particular, see the XML `remarks` tags on class definitions +Shared values such as `Version`, `Copyright` and `PackageOutputPath` are defined in `src/Directory.Build.props`; the csproj values take precedence where both are set. -## README Badges +### Releases (PepperDash Internal) + +Every push runs `.github/workflows/EssentialsPlugins-builds-caller.yml`, which calls the shared workflows in [PepperDash/workflow-templates](https://github.com/PepperDash/workflow-templates) to determine the version with [semantic-release](https://semantic-release.gitbook.io/) (`.releaserc.json`) and build the plugin when there is a new version. + +* Versions are derived from commit messages. A commit scope of `force-patch` forces a patch release and `no-release` skips the release. +* `.releaserc.json` ships with placeholder pre-release settings (`replace-me-feature-branch`, `replace-me-prerelease`). Replace them with your pre-release branch and channel, or remove the entry if you do not use one. + +## Repository Automation + +### README Badges The `.NET` and `PepperDash Essentials` badges at the top of this README are kept in sync by an MSBuild target in `src/Directory.Build.targets`, so no extra tools are needed: @@ -58,7 +96,7 @@ In CI (`CI=true`) the target only checks: a stale badge fails the build with `PD Keep a `MinimumEssentialsFrameworkVersion = "x.y.z";` assignment in at least one factory, and keep the two badges in this README. Renaming the project or factory classes does not require changes. -## Git Hooks (Husky.Net) +### Git Hooks (Husky.Net) This repo uses [Husky.Net](https://alirezanet.github.io/Husky.Net/) for one `commit-msg` hook. It runs `.husky/csx/commit-lint.csx`, which checks the message against [Conventional Commits](https://www.conventionalcommits.org/) using the type list from the shared `checkCommitMessage` workflow in PepperDash/workflow-templates (this template's CI does not currently run that check): @@ -70,7 +108,7 @@ This repo uses [Husky.Net](https://alirezanet.github.io/Husky.Net/) for one `com The only requirement is the [.NET SDK](https://dotnet.microsoft.com/download). -### Setup +#### Setup The hook is installed automatically the first time you do any of the following in a fresh clone: @@ -86,18 +124,161 @@ dotnet husky install Verify with `git config core.hooksPath`, which should print `.husky`. -### Usage +#### Usage * Commit as usual; the hook runs on every `git commit`. * Skip the hook for a single commit: `git commit --no-verify` * Skip the automatic install on restore: set the `HUSKY=0` environment variable. It is also skipped when `CI=true`. -## Build Instructions (PepperDash Internal) +### README Docs (Generated Plugin Documentation) + +The plugin's config example, supported types, join maps, feedbacks and public methods are generated into `` / `` sections under [Plugin Documentation](#plugin-documentation) at the end of this README. Generation runs locally on your current branch, so the result is committed with your changes (there is no CI job or separate branch, and it is not part of the pre-commit hook). + +#### Requirements + +* [Python 3](https://www.python.org/downloads/) on the `PATH` (`python3`, `python` or `py`) +* PowerShell: Windows PowerShell 5.1 (built in) or [PowerShell 7 (`pwsh`)](https://learn.microsoft.com/powershell/scripting/install/installing-powershell) on macOS/Linux +* Internet access on first run (the script downloads `metadata.py` from [PepperDash/workflow-templates](https://github.com/PepperDash/workflow-templates)) + +#### Running the Update + +Run the commands from the repo root; the script always updates the repo root `README.md`. + +| Where | How | +| --- | --- | +| VS Code | `Terminal > Run Task...` > **Update README docs** | +| macOS/Linux terminal | `pwsh -NoProfile -File .github/scripts/Update-ReadmeDocs.ps1` | +| Windows terminal | `powershell -NoProfile -ExecutionPolicy Bypass -File .github\scripts\Update-ReadmeDocs.ps1` | +| Copilot Chat | `/update-readme-docs` - runs the script, then reviews the generated sections against `src/` and cleans them up | + +Then review `git diff README.md` and commit the result with your changes. + +Options: + +* `-Ref ` - download `metadata.py` from a specific `workflow-templates` ref instead of `main` +* `-ScriptPath ` - use a local copy of `metadata.py` (no download) + +#### Controlling the Output + +* The Config Example `type` is the first `TypeNames` entry of the first `*Factory.cs` (by file name), and the unused `uid` property is removed. +* Base Classes and Interfaces list the base classes and interfaces declared on the plugin's own device classes (factories and join maps are excluded). Each has its own section; Interfaces is empty when no device class declares one. +* Sections the generator gets wrong (for example placeholder config values or a join map it can't find) can be edited by hand. Add `` on the line after the `` marker and later runs leave that section alone. +* To hide a section that doesn't apply, keep its markers with only `` between them. Deleting the markers doesn't work; the generator adds them back. + +## License + +Provided under the MIT license; see [LICENSE.md](LICENSE.md). + +## Plugin Documentation + +The sections below are generated; see [README Docs](#readme-docs-generated-plugin-documentation). + + + +### Minimum Essentials Framework Versions + +- 2.12.1 + + + + +### Config Example + +```json +{ + "key": "device-1", + "name": "Example Device", + "type": "examplePluginCrestronDevice", + "group": "pluginDevices", + "properties": { + "control": { + "method": "tcpIp", + "tcpSshProperties": { + "address": "172.22.0.101", + "port": 23, + "username": "admin", + "password": "password", + "autoReconnect": true, + "autoReconnectIntervalMs": 10000 + } + }, + "pollTimeMs": 30000, + "warningTimeoutMs": 180000, + "errorTimeoutMs": 300000, + "DeviceDictionary": { + "item1": { + "name": "Item 1 Name", + "value": 1 + } + } + } +} +``` + + + +### Supported Types + +- examplePluginCrestronDevice +- examplePluginDevice +- examplePluginLogicDevice + + + + +### Join Maps + +#### Digitals + +| Join | Type (RW) | Description | +| --- | --- | --- | +| 1 | R | Is Online | +| 2 | R/W | Connect (Held)/Disconnect (Release) & corresponding feedback | + +#### Analogs + +| Join | Type (RW) | Description | +| --- | --- | --- | +| 1 | R | Socket Status | + +#### Serials + +| Join | Type (RW) | Description | +| --- | --- | --- | +| 1 | R | Device Name | + + + + + + +### Base Classes + +- `CrestronGenericBridgeableBaseDevice` +- `EssentialsBridgeableDevice` + + + +### Public Methods + +- public void SendText(string text) +- public void SendBytes(byte[] bytes) +- public void Poll() + + + +### Bool Feedbacks + +- ConnectFeedback +- OnlineFeedback + -## Generating Nuget Package + +### Int Feedbacks -A nuget package is automatically generated when the plugin is build. To modify the name and other details of the package, edit the following properties in the .csproj file: +- StatusFeedback + -1. `PackageId` - This is the name that will be used to pull the package from Nuget once it's published -2. `PackgeProjectUrl` - This should match the URL for the plugin repo -3. `AssemblyTitle` - This is the dll file name that is will show on a processor when the plugin is loaded \ No newline at end of file + + + From a9f25dccd5fdc17f432ae2ca10fd285a6d07ea4e Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 1 Oct 2026 17:40:42 -0500 Subject: [PATCH 2/7] feat: generate README plugin docs with an MSBuild C# task Replace the Python metadata.py + PowerShell pipeline with build/ReadmeDocs.cs, compiled by RoslynCodeTaskFactory and run by the UpdateReadmeDocs target (dotnet msbuild -t:UpdateReadmeDocs). Only the .NET SDK is required and it runs offline. CI builds (CI=true) regenerate the sections in memory and fail with PDREADME005 when README.md is stale. The port matches metadata.py output except that it is deterministic, lists each Minimum Essentials Framework Version once, finds factories and join maps by content rather than file name (recovering join maps metadata.py skipped), classifies IpTableObjectBase-style names correctly, and lists Base Classes before Interfaces as plain items. Badges now read MinimumEssentialsFrameworkVersion from any project C# file, so factories can be renamed freely. Remove Update-ReadmeDocs.ps1 and update the README, VS Code task and the update-readme-docs skill. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/Update-ReadmeDocs.ps1 | 127 ----- .github/skills/update-readme-docs/SKILL.md | 10 +- .vscode/tasks.json | 24 +- README.md | 38 +- build/ReadmeDocs.cs | 631 +++++++++++++++++++++ src/Directory.Build.targets | 54 +- 6 files changed, 709 insertions(+), 175 deletions(-) delete mode 100644 .github/scripts/Update-ReadmeDocs.ps1 create mode 100644 build/ReadmeDocs.cs diff --git a/.github/scripts/Update-ReadmeDocs.ps1 b/.github/scripts/Update-ReadmeDocs.ps1 deleted file mode 100644 index 36157e3..0000000 --- a/.github/scripts/Update-ReadmeDocs.ps1 +++ /dev/null @@ -1,127 +0,0 @@ -<# -.SYNOPSIS - Regenerates the plugin documentation sections in README.md locally. -.DESCRIPTION - Runs the same metadata.py used by the PepperDash/workflow-templates update-readme - workflow, but against the local working tree so the result lands in your branch. - Sections live between / markers; add - inside a section to stop it from being regenerated. -.PARAMETER Ref - workflow-templates branch or tag to download metadata.py from. -.PARAMETER ScriptPath - Use a local metadata.py instead of downloading one. -#> -[CmdletBinding()] -param( - [string]$Ref = 'main', - [string]$ScriptPath -) - -$ErrorActionPreference = 'Stop' -$root = Resolve-Path (Join-Path $PSScriptRoot '..\..') - -$python = @('python3', 'python', 'py') | - ForEach-Object { Get-Command $_ -ErrorAction SilentlyContinue } | - Where-Object { $_.Source -notmatch 'WindowsApps' } | - Select-Object -First 1 -if (-not $python) { - [Console]::Error.WriteLine('readme-docs: Python 3 not found on PATH (https://www.python.org/downloads/)') - exit 1 -} - -if (-not $ScriptPath) { - $ScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) 'pd-readme-metadata.py' - $url = "https://raw.githubusercontent.com/PepperDash/workflow-templates/$Ref/.github/scripts/metadata.py" - Invoke-WebRequest -Uri $url -OutFile $ScriptPath -UseBasicParsing -} - -# metadata.py logs at DEBUG level; keep only INFO and above -& $python.Source $ScriptPath $root 2>&1 | - Where-Object { "$_" -notmatch '^DEBUG:' } | - ForEach-Object { Write-Host $_ } -if ($LASTEXITCODE -ne 0) { - [Console]::Error.WriteLine("readme-docs: metadata.py failed (exit $LASTEXITCODE)") - exit $LASTEXITCODE -} - -# Config Example: "type" = first TypeNames entry of the first *Factory.cs (by name); "uid" is unused and removed. -# Applied even to sections. -$utf8 = New-Object System.Text.UTF8Encoding($false) -$configType = $null -$factories = Get-ChildItem -Path (Join-Path $root 'src') -Filter '*Factory.cs' -Recurse | - Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' } | - Sort-Object Name -foreach ($file in $factories) { - $code = [regex]::Replace([System.IO.File]::ReadAllText($file.FullName, $utf8), '//.*', '') - $m = [regex]::Match($code, 'TypeNames\s*=\s*new\s+List\s*\(\s*\)\s*\{\s*"([^"]+)"') - if ($m.Success) { $configType = $m.Groups[1].Value; break } -} -$readmePath = Join-Path $root 'README.md' - -# Base Classes and Interfaces: types declared by the plugin's own device classes (not factories or join maps). -# Interfaces go in the generator's "Interfaces Implemented" section, retitled "Interfaces". Sections marked are left alone. -function Split-TopLevel([string]$list) { - $parts = @(); $depth = 0; $cur = '' - foreach ($ch in $list.ToCharArray()) { - if ($ch -eq '<') { $depth++ } elseif ($ch -eq '>') { $depth-- } - if ($ch -eq ',' -and $depth -eq 0) { $parts += $cur.Trim(); $cur = '' } else { $cur += $ch } - } - if ($cur.Trim()) { $parts += $cur.Trim() } - $parts -} -$classPattern = [regex]'(?m)^[ \t]*(?:(?:public|internal|abstract|sealed|static|partial)[ \t]+)*class[ \t]+(\w+)[ \t\r\n]*:([^{]+)\{' -$baseTypes = New-Object System.Collections.Generic.List[string] -$interfaceTypes = New-Object System.Collections.Generic.List[string] -$sources = Get-ChildItem -Path (Join-Path $root 'src') -Filter '*.cs' -Recurse | - Where-Object { $_.FullName -notmatch '[\\/](bin|obj)[\\/]' } | - Sort-Object Name -foreach ($file in $sources) { - $code = [System.IO.File]::ReadAllText($file.FullName, $utf8) - $code = [regex]::Replace([regex]::Replace($code, '(?s)/\*.*?\*/', ''), '//.*', '') - foreach ($m in $classPattern.Matches($code)) { - if ($m.Groups[1].Value -like '*Factory') { continue } - $bases = Split-TopLevel (($m.Groups[2].Value -split '\swhere\s')[0]) - if ($bases -contains 'JoinMapBaseAdvanced') { continue } - foreach ($b in $bases) { - if ($b -match '^I[A-Z]') { - if (-not $interfaceTypes.Contains($b)) { $interfaceTypes.Add($b) } - } - elseif (-not $baseTypes.Contains($b)) { $baseTypes.Add($b) } - } - } -} - -function Set-SectionBody([string]$text, [string]$name, [string]$body) { - $pattern = '(?s)()(.*?)()' - $m = [regex]::Match($text, $pattern) - if (-not $m.Success -or $m.Groups[2].Value -match '') { return $text } - $text.Substring(0, $m.Index) + $m.Groups[1].Value + $body + $m.Groups[3].Value + $text.Substring($m.Index + $m.Length) -} - -if (Test-Path $readmePath) { - $original = [System.IO.File]::ReadAllText($readmePath, $utf8) - $readme = $original - $nl = if ($readme.Contains("`r`n")) { "`r`n" } else { "`n" } - - $section = [regex]::Match($readme, '(?s).*?') - if ($section.Success) { - $fixed = [regex]::Replace($section.Value, '(?m)^[ \t]*"uid":\s*\d+,?[ \t]*\r?\n', '') - if ($configType) { - $fixed = ([regex]'"type":\s*"[^"]*"').Replace($fixed, "`"type`": `"$configType`"", 1) - } - $readme = $readme.Substring(0, $section.Index) + $fixed + $readme.Substring($section.Index + $section.Length) - } - - $listBody = { - param([string]$title, $items) - if (@($items).Count -eq 0) { return $nl } - $nl + "### $title" + $nl + $nl + ((@($items) | ForEach-Object { "- ``$_``" }) -join $nl) + $nl - } - $readme = Set-SectionBody $readme 'Base Classes' (& $listBody 'Base Classes' $baseTypes) - $readme = Set-SectionBody $readme 'Interfaces Implemented' (& $listBody 'Interfaces' $interfaceTypes) - - if ($readme -cne $original) { [System.IO.File]::WriteAllText($readmePath, $readme, $utf8) } -} - -git -C $root diff --stat -- README.md -Write-Host 'readme-docs: review the README.md diff, then commit it with your changes' diff --git a/.github/skills/update-readme-docs/SKILL.md b/.github/skills/update-readme-docs/SKILL.md index 685aef6..70956a8 100644 --- a/.github/skills/update-readme-docs/SKILL.md +++ b/.github/skills/update-readme-docs/SKILL.md @@ -9,13 +9,13 @@ Regenerates the marker-delimited documentation sections in `README.md` on the cu ## Procedure -1. Run `pwsh -NoProfile -File .github/scripts/Update-ReadmeDocs.ps1` (Windows: `powershell -NoProfile -ExecutionPolicy Bypass -File ...`). Requires Python 3. Do not switch branches. +1. From the repo root, run `dotnet msbuild -t:UpdateReadmeDocs` (the `UpdateReadmeDocs` target in `src/Directory.Build.targets`, implemented in `build/ReadmeDocs.cs`). Requires only the .NET SDK. Do not switch branches. 2. Read `git diff README.md` and compare each generated section against the source in `src/`: - - **Minimum Essentials Framework Versions**: collapse duplicates to one entry per distinct value and mark `` (the generator re-adds duplicates otherwise). - - **Supported Types**: must match every `TypeNames` entry in the `*Factory.cs` files. - - **Config Example**: `type` is set by the script to the first `TypeNames` entry of the first `*Factory.cs` (by file name); do not change it by hand. The script also removes the unused `uid` property. Must use the real config class(es), JSON property names and value types (nested objects such as `control` are emitted as `"SampleValue"`). Replace `SampleString`/`SampleValue`/`GeneratedKey` placeholders with realistic values, using the `` marker (see below). Take example values from the `` blocks in `MakeModelConfigObject.cs`. + - **Minimum Essentials Framework Versions**: one entry per distinct `MinimumEssentialsFrameworkVersion` in the factories; do not edit by hand or mark it ``, or it stops following version bumps. + - **Supported Types**: must match every `TypeNames` entry in the plugin's factory classes (any file name). + - **Config Example**: `type` is set by the target to the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; do not change it by hand. The target also removes the unused `uid` property. Must use the real config class(es), JSON property names and value types (nested objects such as `control` are emitted as `"SampleValue"`). Replace `SampleString`/`SampleValue`/`GeneratedKey` placeholders with realistic values, using the `` marker (see below). Take example values from the `` blocks in `MakeModelConfigObject.cs`. - **Join Maps**: if empty, the generator could not find the join map file (it looks for `.cs`). Write the table by hand from the `JoinDataComplete` definitions and mark the section ``. - - **Base Classes / Interfaces**: the script lists the base classes (Base Classes) and interfaces (Interfaces) declared by the plugin's device classes, excluding factories and join maps. Verify against `src/`; do not edit by hand unless you add ``. Interfaces uses the generator's `Interfaces Implemented` markers and is empty when there are none. + - **Base Classes / Interfaces**: the target lists the base classes (Base Classes) and interfaces (Interfaces) declared by the plugin's device classes, excluding factories and join maps. Verify against `src/`; do not edit by hand unless you add ``. Interfaces uses the generator's `Interfaces Implemented` markers and is empty when there are none. - **Public Methods / Feedbacks**: remove noise (non-public API, base classes of factories, template-only members) by hand and mark ``; for a section that does not apply, keep the markers with only `` between them (deleting the markers does not work, the generator re-adds them). 3. Ensure a blank line precedes each `` marker and the file ends with a newline. 4. Do not edit anything outside the ``/`` blocks. diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6bfcf2d..c03e748 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,7 +5,10 @@ "label": "Restore dotnet tools", "type": "process", "command": "dotnet", - "args": ["tool", "restore"], + "args": [ + "tool", + "restore" + ], "presentation": { "reveal": "silent", "close": true @@ -16,7 +19,10 @@ "label": "Install git hooks", "type": "process", "command": "dotnet", - "args": ["husky", "install"], + "args": [ + "husky", + "install" + ], "dependsOn": "Restore dotnet tools", "presentation": { "reveal": "silent", @@ -29,12 +35,14 @@ }, { "label": "Update README docs", - "type": "shell", - "command": "pwsh -NoProfile -File .github/scripts/Update-ReadmeDocs.ps1", - "windows": { - "command": "powershell -NoProfile -ExecutionPolicy Bypass -File .github/scripts/Update-ReadmeDocs.ps1" - }, - "problemMatcher": [] + "type": "process", + "command": "dotnet", + "args": [ + "msbuild", + "-nologo", + "-t:UpdateReadmeDocs" + ], + "problemMatcher": "$msCompile" } ] } diff --git a/README.md b/README.md index 769d830..ecd2dbb 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Every push runs `.github/workflows/EssentialsPlugins-builds-caller.yml`, which c The `.NET` and `PepperDash Essentials` badges at the top of this README are kept in sync by an MSBuild target in `src/Directory.Build.targets`, so no extra tools are needed: * `.NET` badge - `TargetFramework` of the plugin project -* `PepperDash Essentials` badge - the highest `MinimumEssentialsFrameworkVersion` in the project's `*Factory.cs` files (subfolders included) +* `PepperDash Essentials` badge - the highest `MinimumEssentialsFrameworkVersion = "x.y.z";` assignment in the project's C# files (subfolders included), so factories can be renamed freely (for example `SonyBraviaDeviceFactory`) Every local `dotnet build` (or Visual Studio build) rewrites the badges when they are stale; commit the `README.md` change with your code. The `PepperDashEssentials` `PackageReference` version in the csproj must be equal to or greater than `MinimumEssentialsFrameworkVersion` (a prerelease such as `2.13.0-beta` counts as lower than `2.13.0`); otherwise the build fails with `PDREADME002`. @@ -132,35 +132,26 @@ Verify with `git config core.hooksPath`, which should print `.husky`. ### README Docs (Generated Plugin Documentation) -The plugin's config example, supported types, join maps, feedbacks and public methods are generated into `` / `` sections under [Plugin Documentation](#plugin-documentation) at the end of this README. Generation runs locally on your current branch, so the result is committed with your changes (there is no CI job or separate branch, and it is not part of the pre-commit hook). +The plugin's config example, supported types, join maps, feedbacks and public methods are generated into `` / `` sections under [Plugin Documentation](#plugin-documentation) at the end of this README. Generation is an MSBuild target (`UpdateReadmeDocs` in `src/Directory.Build.targets`, implemented in `build/ReadmeDocs.cs`) that you run on your branch, review, and commit with your changes. It needs only the .NET SDK and runs offline. -#### Requirements - -* [Python 3](https://www.python.org/downloads/) on the `PATH` (`python3`, `python` or `py`) -* PowerShell: Windows PowerShell 5.1 (built in) or [PowerShell 7 (`pwsh`)](https://learn.microsoft.com/powershell/scripting/install/installing-powershell) on macOS/Linux -* Internet access on first run (the script downloads `metadata.py` from [PepperDash/workflow-templates](https://github.com/PepperDash/workflow-templates)) +In CI (`CI=true`) the build regenerates the sections in memory and fails with `PDREADME005` if they differ from the committed README, so a release is never packaged with out-of-date docs. Skip that check with `-p:SkipReadmeDocsCheck=true`. #### Running the Update -Run the commands from the repo root; the script always updates the repo root `README.md`. - | Where | How | | --- | --- | | VS Code | `Terminal > Run Task...` > **Update README docs** | -| macOS/Linux terminal | `pwsh -NoProfile -File .github/scripts/Update-ReadmeDocs.ps1` | -| Windows terminal | `powershell -NoProfile -ExecutionPolicy Bypass -File .github\scripts\Update-ReadmeDocs.ps1` | -| Copilot Chat | `/update-readme-docs` - runs the script, then reviews the generated sections against `src/` and cleans them up | +| Terminal (repo root) | `dotnet msbuild -t:UpdateReadmeDocs` | +| Check only, no changes | `dotnet build -p:ReadmeMode=Check` | +| Copilot Chat | `/update-readme-docs` - runs the target, then reviews the generated sections against `src/` and cleans them up | Then review `git diff README.md` and commit the result with your changes. -Options: - -* `-Ref ` - download `metadata.py` from a specific `workflow-templates` ref instead of `main` -* `-ScriptPath ` - use a local copy of `metadata.py` (no download) +The generator is a C# port of `metadata.py` from [PepperDash/workflow-templates](https://github.com/PepperDash/workflow-templates) (used by the older `update-readme` workflow) and produces the same sections, except that the output order no longer depends on the file system. #### Controlling the Output -* The Config Example `type` is the first `TypeNames` entry of the first `*Factory.cs` (by file name), and the unused `uid` property is removed. +* The Config Example `type` is the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`, and the unused `uid` property is removed. Factories, join maps and config classes are found by their content, not their file or class names, so renaming them (for example to `SonyBraviaDeviceFactory`) needs no changes here. * Base Classes and Interfaces list the base classes and interfaces declared on the plugin's own device classes (factories and join maps are excluded). Each has its own section; Interfaces is empty when no device class declares one. * Sections the generator gets wrong (for example placeholder config values or a join map it can't find) can be edited by hand. Add `` on the line after the `` marker and later runs leave that section alone. * To hide a section that doesn't apply, keep its markers with only `` between them. Deleting the markers doesn't work; the generator adds them back. @@ -174,10 +165,9 @@ Provided under the MIT license; see [LICENSE.md](LICENSE.md). The sections below are generated; see [README Docs](#readme-docs-generated-plugin-documentation). - ### Minimum Essentials Framework Versions -- 2.12.1 +- 2.42.4 @@ -248,16 +238,16 @@ The sections below are generated; see [README Docs](#readme-docs-generated-plugi | 1 | R | Device Name | - - - ### Base Classes -- `CrestronGenericBridgeableBaseDevice` -- `EssentialsBridgeableDevice` +- CrestronGenericBridgeableBaseDevice +- EssentialsBridgeableDevice + + + ### Public Methods diff --git a/build/ReadmeDocs.cs b/build/ReadmeDocs.cs new file mode 100644 index 0000000..a0aad91 --- /dev/null +++ b/build/ReadmeDocs.cs @@ -0,0 +1,631 @@ +// README plugin documentation generator, compiled at build time by RoslynCodeTaskFactory +// (see the UpdateReadmeDocs / CheckReadmeDocs targets in src/Directory.Build.targets). +// +// Port of metadata.py from PepperDash/workflow-templates (commit 681f68e), followed by the template's +// post-processing (Config Example type/uid, Base Classes and Interfaces). Output matches metadata.py +// except that each Minimum Essentials Framework Version is listed once, and that it is deterministic: +// source files are read in sorted folder order and each file's Supported Types keep their declared +// order (metadata.py used os.walk order and an unordered set). +// +// Must stay compatible with netstandard2.0 / C# 7.3 so it also compiles under Visual Studio's MSBuild. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +public class GenerateReadmeDocs : Task +{ + /// README.md to update or check. + [Required] public string ReadmePath { get; set; } + + /// The project's own C# files; RelPath metadata (relative to the project) sets the read order. + [Required] public ITaskItem[] SourceFiles { get; set; } + + /// Fail when README.md is stale instead of rewriting it. + public bool CheckOnly { get; set; } + + private static readonly UTF8Encoding Utf8 = new UTF8Encoding(false); + + public override bool Execute() + { + var sources = SourceFiles + .Select(i => new SourceFile( + RelativePath(i), + ReadmeDocs.NormalizeNewlines(File.ReadAllText(i.GetMetadata("FullPath"), Utf8)))) + .ToList(); + sources.Sort((a, b) => ReadmeDocs.WalkOrder(a.RelPath, b.RelPath)); + + var original = File.ReadAllText(ReadmePath, Utf8); + var generated = ReadmeDocs.Generate(ReadmeDocs.NormalizeNewlines(original), sources, + message => Log.LogMessage(MessageImportance.Normal, "readme-docs: " + message)); + + if (string.Equals(generated, ReadmeDocs.NormalizeNewlines(original), StringComparison.Ordinal)) + { + Log.LogMessage(MessageImportance.High, "readme-docs: README.md up to date"); + return true; + } + if (CheckOnly) + { + Log.LogError(null, "PDREADME005", null, ReadmePath, 0, 0, 0, 0, + "README.md plugin documentation is stale; run 'dotnet msbuild -t:UpdateReadmeDocs' locally, review and commit README.md"); + return false; + } + + // Keep the line endings the README was checked out with + if (original.Contains("\r\n")) generated = generated.Replace("\n", "\r\n"); + File.WriteAllText(ReadmePath, generated, Utf8); + Log.LogMessage(MessageImportance.High, "readme-docs: README.md updated; review the diff, then commit it with your changes"); + return true; + } + + private static string RelativePath(ITaskItem item) + { + var rel = item.GetMetadata("RelPath"); + return string.IsNullOrEmpty(rel) ? item.ItemSpec : rel; + } +} + +public class SourceFile +{ + public SourceFile(string relPath, string content) + { + RelPath = relPath.Replace('\\', '/'); + Content = content; + } + + public string RelPath { get; private set; } + public string Content { get; private set; } + public string FileName { get { return RelPath.Substring(RelPath.LastIndexOf('/') + 1); } } +} + +public static class ReadmeDocs +{ + public static string NormalizeNewlines(string text) + { + return text.Replace("\r\n", "\n").Replace('\r', '\n'); + } + + /// os.walk order with sorted entries: a folder's files before its subfolders, each sorted ordinally. + public static int WalkOrder(string a, string b) + { + var pa = a.Replace('\\', '/').Split('/'); + var pb = b.Replace('\\', '/').Split('/'); + for (var i = 0; ; i++) + { + var aIsFile = i == pa.Length - 1; + var bIsFile = i == pb.Length - 1; + if (aIsFile != bIsFile) return aIsFile ? -1 : 1; + var c = string.CompareOrdinal(pa[i], pb[i]); + if (c != 0 || aIsFile) return c; + } + } + + public static string Generate(string readme, IList sources, Action log) + { + // ---- metadata.py ---- + var supportedTypes = new List(); + var minimumVersions = new List(); + var publicMethods = new List(); + var boolFeedbacks = new List(); + var intFeedbacks = new List(); + var stringFeedbacks = new List(); + foreach (var source in sources) + { + // De-duplicated per file only, as in metadata.py + supportedTypes.AddRange(ExtractSupportedTypes(source.Content).Distinct()); + var version = MinimumVersionPattern.Match(source.Content); + // Listed once per distinct value (metadata.py repeated it for every factory) + if (version.Success && !minimumVersions.Contains(version.Groups[1].Value)) minimumVersions.Add(version.Groups[1].Value); + foreach (Match m in PublicMethodPattern.Matches(source.Content)) publicMethods.Add(m.Value.Trim()); + var uncommented = LineComment.Replace(source.Content, ""); + AddFeedbacks(BoolFeedbackPattern, uncommented, boolFeedbacks); + AddFeedbacks(IntFeedbackPattern, uncommented, intFeedbacks); + AddFeedbacks(StringFeedbackPattern, uncommented, stringFeedbacks); + } + + var joins = new List(); + foreach (var joinMap in FindJoinMapClasses(sources)) + joins.AddRange(ParseJoinMap(joinMap, sources, log)); + + string configExample = ""; + var classDefs = ParseAllClasses(sources); + var configClasses = classDefs.Keys.Where(c => c.EndsWith("Config", StringComparison.Ordinal) || c.EndsWith("ConfigObject", StringComparison.Ordinal)).ToList(); + if (configClasses.Count == 0) + { + log("no config classes found"); + } + else + { + var main = configClasses[0]; + foreach (var c in configClasses) + if (classDefs[c].Count > classDefs[main].Count) main = c; + configExample = "### Config Example\n\n```json\n" + Json.Write(SampleConfig(main, classDefs, supportedTypes), 0) + "\n```\n"; + } + + readme = UpdateSection(readme, "Minimum Essentials Framework Versions", MarkdownList(minimumVersions, "Minimum Essentials Framework Versions")); + if (configExample.Length > 0) readme = UpdateSection(readme, "Config Example", configExample); + readme = UpdateSection(readme, "Supported Types", MarkdownList(supportedTypes, "Supported Types")); + readme = UpdateSection(readme, "Join Maps", JoinMapChart(joins)); + // Base Classes and Interfaces Implemented bodies are replaced by the post-processing below + readme = UpdateSection(readme, "Base Classes", ""); + readme = UpdateSection(readme, "Interfaces Implemented", ""); + readme = BaseClassesBeforeInterfaces(readme); + readme = UpdateSection(readme, "Public Methods", MarkdownList(publicMethods, "Public Methods")); + readme = UpdateSection(readme, "Bool Feedbacks", MarkdownList(boolFeedbacks, "Bool Feedbacks")); + readme = UpdateSection(readme, "Int Feedbacks", MarkdownList(intFeedbacks, "Int Feedbacks")); + readme = UpdateSection(readme, "String Feedbacks", MarkdownList(stringFeedbacks, "String Feedbacks")); + + // ---- template post-processing ---- + var byName = sources.OrderBy(s => s.FileName, StringComparer.OrdinalIgnoreCase).ToList(); + readme = FixConfigExample(readme, FindConfigType(byName)); + var baseTypes = new List(); + var interfaceTypes = new List(); + CollectDeclaredTypes(byName, baseTypes, interfaceTypes); + readme = SetSectionBody(readme, "Base Classes", DeclaredTypesBody("Base Classes", baseTypes)); + readme = SetSectionBody(readme, "Interfaces Implemented", DeclaredTypesBody("Interfaces", interfaceTypes)); + return readme; + } + + // ---------------------------------------------------------------- metadata.py extraction + + private static readonly Regex LineComment = new Regex("//.*"); + private static readonly Regex BlockComment = new Regex(@"/\*.*?\*/", RegexOptions.Singleline); + private static readonly Regex TypeNamesPattern = new Regex(@"TypeNames\s*=\s*new\s*List\(\)\s*\{([^}]+)\}"); + private static readonly Regex MinimumVersionPattern = new Regex(@"^\s*MinimumEssentialsFrameworkVersion\s*=\s*""([^""]+)""\s*;", RegexOptions.Multiline); + private static readonly Regex PublicMethodPattern = new Regex(@"public\s+\w+\s+\w+\s*\([^)]*\)\s*"); + private static readonly Regex BoolFeedbackPattern = FeedbackPattern("BoolFeedback"); + private static readonly Regex IntFeedbackPattern = FeedbackPattern("IntFeedback"); + private static readonly Regex StringFeedbackPattern = FeedbackPattern("StringFeedback"); + + private static Regex FeedbackPattern(string type) + { + return new Regex(@"public\s+" + type + @"\s+(\w+)(?:\s*\{[^}]*\}|\s*;|\s*=)"); + } + + private static IEnumerable ExtractSupportedTypes(string content) + { + foreach (Match m in TypeNamesPattern.Matches(LineComment.Replace(content, ""))) + foreach (var item in m.Groups[1].Value.Split(',')) + { + var type = item.Trim().Trim('"'); + if (type.Length > 0) yield return type; + } + } + + private static void AddFeedbacks(Regex pattern, string content, List target) + { + foreach (Match m in pattern.Matches(content)) + { + var name = m.Groups[1].Value.Trim(); + if (name.Length > 0) target.Add(name); + } + } + + private static string MarkdownList(List items, string title) + { + if (items.Count == 0) return ""; + var sb = new StringBuilder("### " + title + "\n\n"); + foreach (var item in items) sb.Append("- ").Append(item).Append('\n'); + return sb.Append('\n').ToString(); + } + + // ---------------------------------------------------------------- join maps + + private static readonly Regex ClassWithBasesPattern = new Regex( + @"^\s*(?:\[[^\]]+\]\s*)*(?:public\s+|private\s+|protected\s+)?(?:partial\s+)?class\s+([A-Za-z_]\w*)(?:\s*:\s*([^\{]+))?\s*\{", + RegexOptions.Multiline); + + private static readonly Regex JoinPattern = new Regex( + @"\[JoinName\(""(?[^""]+)""\)\]\s*" + + @"public\s+JoinDataComplete\s+(?\w+)\s*=\s*" + + @"new\s+JoinDataComplete\s*\(\s*" + + @"(?.*?)\)\s*;", + RegexOptions.Singleline); + + private static readonly Regex JoinDataPattern = new Regex(@"new\s+JoinData\s*(?:\(\s*\))?\s*\{(.*?)\}", RegexOptions.Singleline); + private static readonly Regex JoinMetadataPattern = new Regex(@"new\s+JoinMetadata\s*(?:\(\s*\))?\s*\{(.*?)\}", RegexOptions.Singleline); + + private class JoinInfo + { + public string Number; + public string Type; + public string Description; + } + + private static List FindJoinMapClasses(IList sources) + { + // Python dict semantics: first-seen order, last definition wins + var order = new List(); + var bases = new Dictionary>(); + foreach (var source in sources) + foreach (Match m in ClassWithBasesPattern.Matches(source.Content)) + { + var name = m.Groups[1].Value; + if (!bases.ContainsKey(name)) order.Add(name); + bases[name] = m.Groups[2].Success + ? m.Groups[2].Value.Split(',').Select(b => b.Trim()).ToList() + : new List(); + } + return order.Where(n => bases[n].Contains("JoinMapBaseAdvanced")).ToList(); + } + + private static IEnumerable ParseJoinMap(string className, IList sources, Action log) + { + // metadata.py reads the join map from .cs; fall back to the file that declares the + // class, so a join map still works when its file is not renamed along with the class + var declares = new Regex(@"\bclass\s+" + Regex.Escape(className) + @"\b"); + var file = sources.FirstOrDefault(s => s.FileName == className + ".cs") + ?? sources.FirstOrDefault(s => declares.IsMatch(s.Content)); + if (file == null) + { + log("join map file not found: " + className + ".cs; skipping"); + yield break; + } + + var content = BlockComment.Replace(LineComment.Replace(file.Content, ""), ""); + foreach (Match match in JoinPattern.Matches(content)) + { + var joinName = match.Groups["join_name"].Value; + var joinParams = match.Groups["join_params"].Value; + string number = null, description = null, type = null; + + var data = JoinDataPattern.Match(joinParams); + if (data.Success) + { + var m = Regex.Match(data.Groups[1].Value, @"JoinNumber\s*=\s*(\d+)"); + if (m.Success) number = m.Groups[1].Value; + } + var metadata = JoinMetadataPattern.Match(joinParams); + if (metadata.Success) + { + var d = Regex.Match(metadata.Groups[1].Value, @"Description\s*=\s*""([^""]+)"""); + if (d.Success) description = d.Groups[1].Value; + var t = Regex.Match(metadata.Groups[1].Value, @"JoinType\s*=\s*eJoinType\.(\w+)"); + if (t.Success) type = t.Groups[1].Value; + } + + if (joinName.Length > 0 && number != null && type != null) + yield return new JoinInfo { Number = number, Type = type, Description = description }; + else + log("incomplete join information for '" + joinName + "'; skipping"); + } + } + + private static string JoinMapChart(List joins) + { + if (joins.Count == 0) return ""; + var sb = new StringBuilder("### Join Maps\n\n"); + foreach (var kind in new[] { "Digital", "Analog", "Serial" }) + { + // Unrecognized join types (e.g. DigitalSerial) are listed as Digital + var ofKind = joins.Where(j => j.Type == kind || (kind == "Digital" && j.Type != "Analog" && j.Type != "Serial")).ToList(); + if (ofKind.Count == 0) continue; + sb.Append("#### ").Append(kind).Append("s\n\n"); + sb.Append("| Join | Type (RW) | Description |\n"); + sb.Append("| --- | --- | --- |\n"); + foreach (var j in ofKind) + sb.Append("| ").Append(j.Number).Append(" | R | ").Append(j.Description ?? "None").Append(" |\n"); + sb.Append('\n'); + } + return sb.ToString(); + } + + // ---------------------------------------------------------------- config example + + private static readonly Regex ClassPattern = new Regex( + @"^\s*(?:\[[^\]]+\]\s*)*(?:public\s+|private\s+|protected\s+)?(?:partial\s+)?class\s+([A-Za-z_]\w*)(?:\s*:\s*[^\{]+)?\s*\{", + RegexOptions.Multiline); + + private static readonly Regex PropertyPattern = new Regex( + @"^\s*(?:\[[^\]]*\]\s*)*(?:public|private|protected)\s+(?:static\s+|virtual\s+|override\s+|abstract\s+|readonly\s+)?" + + @"([A-Za-z0-9_<>,\s\[\]\?]+?)\s+([A-Za-z_]\w*)\s*\{[^}]*?\}", + RegexOptions.Multiline | RegexOptions.Singleline); + + private static readonly Regex JsonPropertyPattern = new Regex(@"\[JsonProperty\(""([^""]+)""\)\]"); + + private class PropertyDef + { + public string JsonName; + public string Type; + } + + private class ClassDefs + { + public readonly List Keys = new List(); + private readonly Dictionary> _map = new Dictionary>(); + + public List this[string name] + { + get { return _map[name]; } + set + { + if (!_map.ContainsKey(name)) Keys.Add(name); + _map[name] = value; + } + } + + public bool Contains(string name) { return _map.ContainsKey(name); } + } + + private static ClassDefs ParseAllClasses(IList sources) + { + var defs = new ClassDefs(); + foreach (var source in sources) + { + var content = source.Content; + foreach (Match classMatch in ClassPattern.Matches(content)) + { + var body = ClassBody(content, classMatch.Index + classMatch.Length); + var properties = new List(); + foreach (Match prop in PropertyPattern.Matches(body)) + { + var json = JsonPropertyPattern.Match(prop.Value); + properties.Add(new PropertyDef + { + JsonName = json.Success ? json.Groups[1].Value : prop.Groups[2].Value, + Type = prop.Groups[1].Value.Trim(), + }); + } + defs[classMatch.Groups[1].Value] = properties; + } + } + return defs; + } + + private static string ClassBody(string content, int start) + { + var depth = 1; + var index = start; + while (depth > 0 && index < content.Length) + { + if (content[index] == '{') depth++; + else if (content[index] == '}') depth--; + index++; + } + return content.Substring(start, Math.Max(0, index - 1 - start)); + } + + private static Json.Object SampleConfig(string configClass, ClassDefs defs, List supportedTypes) + { + var typeName = configClass.Substring(0, Math.Max(0, configClass.Length - 6)); + if (!supportedTypes.Contains(typeName) && supportedTypes.Count > 0) typeName = supportedTypes[0]; + var config = new Json.Object(); + config.Set("key", "GeneratedKey"); + config.Set("uid", 1); + config.Set("name", "GeneratedName"); + config.Set("type", typeName); + config.Set("group", "Group"); + config.Set("properties", SampleValue(configClass, defs, new HashSet())); + return config; + } + + private static readonly string[] CollectionPrefixes = { "List<", "IList<", "IEnumerable<", "ObservableCollection<" }; + + private static object SampleValue(string propertyType, ClassDefs defs, HashSet processing) + { + var type = propertyType.Trim().TrimEnd('?'); + switch (type) + { + case "int": case "long": case "float": case "double": case "decimal": return 0; + case "string": return "SampleString"; + case "bool": return true; + case "DateTime": return "2021-01-01T00:00:00Z"; + } + if (CollectionPrefixes.Any(p => type.StartsWith(p, StringComparison.Ordinal))) + return new List { SampleValue(GenericArguments(type), defs, processing) }; + if (type.StartsWith("Dictionary<", StringComparison.Ordinal)) + { + var args = GenericArguments(type).Split(','); + var key = SampleValue(args[0].Trim(), defs, processing); + var value = args.Length > 1 ? SampleValue(args[1].Trim(), defs, processing) : "SampleValue"; + var dict = new Json.Object(); + dict.Set(Json.KeyString(key), value); + return dict; + } + if (defs.Contains(type)) + { + if (processing.Contains(type)) return new Json.Object(); + processing.Add(type); + var obj = new Json.Object(); + foreach (var prop in defs[type]) obj.Set(prop.JsonName, SampleValue(prop.Type, defs, processing)); + processing.Remove(type); + return obj; + } + return "SampleValue"; + } + + private static string GenericArguments(string type) + { + var start = type.IndexOf('<') + 1; + return type.Substring(start, Math.Max(0, type.Length - 1 - start)); + } + + // ---------------------------------------------------------------- README sections + + private static string UpdateSection(string readme, string title, string content) + { + var start = ""; + var end = ""; + var match = Regex.Match(readme, Regex.Escape(start) + "(.*?)" + Regex.Escape(end), RegexOptions.Singleline | RegexOptions.IgnoreCase); + if (match.Success) + { + if (match.Groups[1].Value.Contains("")) return readme; + return readme.Substring(0, match.Index) + start + "\n" + content.TrimEnd() + "\n" + end + readme.Substring(match.Index + match.Length); + } + if (!readme.EndsWith("\n", StringComparison.Ordinal)) readme += "\n"; + return readme + start + "\n" + content.TrimEnd() + "\n" + end + "\n"; + } + + // Swaps the two marker blocks (with their contents) when Interfaces comes first + private static string BaseClassesBeforeInterfaces(string readme) + { + var interfaces = Regex.Match(readme, @"(?s).*?"); + var baseClasses = Regex.Match(readme, @"(?s).*?"); + if (!interfaces.Success || !baseClasses.Success || baseClasses.Index < interfaces.Index) return readme; + var between = interfaces.Index + interfaces.Length; + return readme.Substring(0, interfaces.Index) + baseClasses.Value + + readme.Substring(between, baseClasses.Index - between) + + interfaces.Value + readme.Substring(baseClasses.Index + baseClasses.Length); + } + + private static string SetSectionBody(string text, string name, string body) + { + var m = Regex.Match(text, "(?s)()(.*?)()"); + if (!m.Success || m.Groups[2].Value.Contains("")) return text; + return text.Substring(0, m.Index) + m.Groups[1].Value + body + m.Groups[3].Value + text.Substring(m.Index + m.Length); + } + + // ---------------------------------------------------------------- template post-processing + + // Config Example "type" = first TypeNames entry of the first file (by name) that sets TypeNames, + // so factories can have any class or file name (e.g. SonyBraviaDeviceFactory) + private static string FindConfigType(List byName) + { + var typeNames = new Regex(@"TypeNames\s*=\s*new\s+List\s*\(\s*\)\s*\{\s*""([^""]+)"""); + foreach (var source in byName) + { + var m = typeNames.Match(LineComment.Replace(source.Content, "")); + if (m.Success) return m.Groups[1].Value; + } + return null; + } + + // Applied even to sections: the unused "uid" is removed and "type" is set + private static string FixConfigExample(string readme, string configType) + { + var section = Regex.Match(readme, @"(?s).*?"); + if (!section.Success) return readme; + var fixedText = Regex.Replace(section.Value, @"(?m)^[ \t]*""uid"":\s*\d+,?[ \t]*\n", ""); + if (configType != null) + { + var replacement = "\"type\": \"" + configType + "\""; + fixedText = new Regex(@"""type"":\s*""[^""]*""").Replace(fixedText, _ => replacement, 1); + } + return readme.Substring(0, section.Index) + fixedText + readme.Substring(section.Index + section.Length); + } + + // Base classes and interfaces declared by the plugin's own classes (not factories or join maps) + private static void CollectDeclaredTypes(List byName, List baseTypes, List interfaceTypes) + { + var classPattern = new Regex(@"(?m)^[ \t]*(?:(?:public|internal|abstract|sealed|static|partial)[ \t]+)*class[ \t]+(\w+)[ \t\r\n]*:([^{]+)\{"); + foreach (var source in byName) + { + var code = LineComment.Replace(BlockComment.Replace(source.Content, ""), ""); + foreach (Match m in classPattern.Matches(code)) + { + var bases = SplitTopLevel(Regex.Split(m.Groups[2].Value, @"\swhere\s")[0]); + if (bases.Contains("JoinMapBaseAdvanced") || IsFactory(m.Groups[1].Value, bases)) continue; + foreach (var b in bases) + { + var target = Regex.IsMatch(b, "^I[A-Z]") ? interfaceTypes : baseTypes; + if (!target.Contains(b)) target.Add(b); + } + } + } + } + + // Factories are recognized by their Essentials base class, whatever they are named + private static bool IsFactory(string className, List bases) + { + return className.EndsWith("Factory", StringComparison.Ordinal) + || bases.Any(b => Regex.IsMatch(b, @"^Essentials\w*Factory\s*<")); + } + + private static List SplitTopLevel(string list) + { + var parts = new List(); + var depth = 0; + var current = new StringBuilder(); + foreach (var ch in list) + { + if (ch == '<') depth++; + else if (ch == '>') depth--; + if (ch == ',' && depth == 0) { parts.Add(current.ToString().Trim()); current.Clear(); } + else current.Append(ch); + } + if (current.ToString().Trim().Length > 0) parts.Add(current.ToString().Trim()); + return parts; + } + + private static string DeclaredTypesBody(string title, List items) + { + if (items.Count == 0) return "\n"; + return "\n### " + title + "\n\n" + string.Join("\n", items.Select(i => "- " + i)) + "\n"; + } +} + +/// Minimal writer matching Python's json.dumps(value, indent=4). +public static class Json +{ + public class Object + { + public readonly List> Items = new List>(); + + // Python dict assignment: an existing key keeps its position + public void Set(string key, object value) + { + var i = Items.FindIndex(p => p.Key == key); + if (i >= 0) Items[i] = new KeyValuePair(key, value); + else Items.Add(new KeyValuePair(key, value)); + } + } + + public static string KeyString(object key) + { + if (key is bool) return (bool)key ? "true" : "false"; + if (key is int) return ((int)key).ToString(System.Globalization.CultureInfo.InvariantCulture); + return key as string ?? "SampleValue"; + } + + public static string Write(object value, int level) + { + var obj = value as Object; + if (obj != null) + { + if (obj.Items.Count == 0) return "{}"; + var inner = new string(' ', (level + 1) * 4); + return "{\n" + string.Join(",\n", obj.Items.Select(p => inner + Quote(p.Key) + ": " + Write(p.Value, level + 1))) + + "\n" + new string(' ', level * 4) + "}"; + } + var list = value as List; + if (list != null) + { + if (list.Count == 0) return "[]"; + var inner = new string(' ', (level + 1) * 4); + return "[\n" + string.Join(",\n", list.Select(v => inner + Write(v, level + 1))) + + "\n" + new string(' ', level * 4) + "]"; + } + if (value is bool) return (bool)value ? "true" : "false"; + if (value is int) return ((int)value).ToString(System.Globalization.CultureInfo.InvariantCulture); + return Quote((string)value); + } + + // ensure_ascii=True escaping + private static string Quote(string s) + { + var sb = new StringBuilder("\""); + foreach (var c in s) + { + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + default: + if (c < 0x20 || c > 0x7E) sb.Append("\\u").Append(((int)c).ToString("x4")); + else sb.Append(c); + break; + } + } + return sb.Append('"').ToString(); + } +} diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 95e93f1..f378f09 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -25,7 +25,8 @@ @@ -51,7 +52,7 @@ public class SyncReadmeBadges : Task [Required] public string ReadmePath { get; set; } [Required] public string TargetFramework { get; set; } public string EssentialsPackageVersion { get; set; } - public ITaskItem[] FactoryFiles { get; set; } + public ITaskItem[] SourceFiles { get; set; } public bool CheckOnly { get; set; } private static readonly Regex MinVersionPattern = @@ -63,7 +64,7 @@ public class SyncReadmeBadges : Task Version minVersion = null; string minText = null; - foreach (var item in FactoryFiles ?? new ITaskItem[0]) + foreach (var item in SourceFiles ?? new ITaskItem[0]) { var code = File.ReadAllText(item.GetMetadata("FullPath"), utf8); foreach (Match m in MinVersionPattern.Matches(code)) @@ -74,7 +75,7 @@ public class SyncReadmeBadges : Task } if (minVersion == null) { - Error("PDREADME001", "MinimumEssentialsFrameworkVersion not found in any *Factory.cs file"); + Error("PDREADME001", "no MinimumEssentialsFrameworkVersion = \"x.y.z\"; assignment found in the project's C# files"); return false; } @@ -173,10 +174,7 @@ public class SyncReadmeBadges : Task - - - <_ReadmeFactoryFiles Include="@(Compile)" Condition="$([System.String]::Copy('%(Filename)').EndsWith('Factory'))" /> - + <_ReadmeEssentialsVersion>@(PackageReference->WithMetadataValue('Identity', 'PepperDashEssentials')->'%(Version)') @@ -184,9 +182,9 @@ public class SyncReadmeBadges : Task - + @@ -197,6 +195,40 @@ public class SyncReadmeBadges : Task - + + + + + + <_ReadmeDocsEnabled Condition="'$(ProjectType)' == 'ProgramLibrary' And '$(DesignTimeBuild)' != 'true' And '$(TargetFramework)' != '' And Exists('$(ReadmePath)')">true + + + + + + + + + + + + <_ReadmeSource Include="@(Compile)" RelPath="$([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '%(FullPath)'))" /> + <_ReadmeSource Remove="@(_ReadmeSource)" Condition="$([System.String]::Copy('%(RelPath)').StartsWith('..')) Or $([System.String]::Copy('%(RelPath)').StartsWith('obj')) Or $([System.String]::Copy('%(RelPath)').StartsWith('bin'))" /> + + + + + + + + + + From 4de0987e9281c4f9ddc8ced01bc54b8bc7e49528 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 1 Oct 2026 17:42:13 -0500 Subject: [PATCH 3/7] chore: update copyright year to 2026 and trim blank lines Co-Authored-By: Claude Opus 5.5 --- src/Directory.Build.props | 2 +- src/MakeModelDevice.cs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 206b6c0..60185df 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ PepperDash Technology PepperDash Technology PepperDash Essentials Plugin Template - Copyright © 2025 + Copyright © 2026 https://github.com/PepperDash/EssentialsPluginTemplate.git git Crestron; 4series diff --git a/src/MakeModelDevice.cs b/src/MakeModelDevice.cs index 6a8c986..792679a 100644 --- a/src/MakeModelDevice.cs +++ b/src/MakeModelDevice.cs @@ -184,7 +184,6 @@ void ProcessFeedbackMessage(string message) } - // TODO [ ] If not using an ACII based API, delete the properties below /// /// Sends text to the device plugin comms @@ -288,7 +287,6 @@ private void UpdateFeedbacks() } #endregion - } } From 808120c2b463c6f81aaf284566f3001831350c2e Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 1 Oct 2026 17:51:46 -0500 Subject: [PATCH 4/7] chore: align dates, package metadata and docs across the template - Copyright: README heading 2026, LICENSE "2020-2026" (placeholder brackets removed); the csproj no longer overrides the props copyright - csproj keeps only plugin-specific values; shared Version, Company, Authors and Copyright come from Directory.Build.props. PackageId uses "PepperDash" casing, PackageProjectUrl drops ".git", PackageTags are set once, and the duplicate hard-coded .cplz None Remove lines are removed - Remove the uncompiled Properties/AssemblyInfo.cs and empty ControlSystem.cfg and .gitmodules - Factory and class doc comments: rename examples use the current class names, valid MinimumEssentialsFrameworkVersion examples, per-category TypeNames examples, and correct BuildDevice summaries; fix ASCII, delimiter and TODO typos - Docs: README, skill, generator header and commit-msg hook comment describe the generator's actual differences from metadata.py and CI behavior Co-Authored-By: Claude Opus 5.5 --- .github/skills/update-readme-docs/SKILL.md | 2 +- .gitmodules | 0 .husky/commit-msg | 2 +- LICENSE.md | 2 +- README.md | 6 ++-- build/ReadmeDocs.cs | 11 +++++-- src/Directory.Build.props | 1 - src/MakeModelBridgeJoinMap.cs | 2 +- src/MakeModelConfigObject.cs | 2 +- src/MakeModelCrestronDevice.cs | 2 +- src/MakeModelCrestronDeviceFactory.cs | 8 ++--- src/MakeModelDevice.cs | 10 +++---- src/MakeModelDeviceFactory.cs | 8 ++--- src/MakeModelLogicDevice.cs | 2 +- src/MakeModelLogicDeviceFactory.cs | 8 ++--- src/Properties/AssemblyInfo.cs | 8 ----- src/Properties/ControlSystem.cfg | 0 src/epi-make-model.4Series.csproj | 34 +++++----------------- 18 files changed, 43 insertions(+), 65 deletions(-) delete mode 100644 .gitmodules delete mode 100644 src/Properties/AssemblyInfo.cs delete mode 100644 src/Properties/ControlSystem.cfg diff --git a/.github/skills/update-readme-docs/SKILL.md b/.github/skills/update-readme-docs/SKILL.md index 70956a8..02da1d3 100644 --- a/.github/skills/update-readme-docs/SKILL.md +++ b/.github/skills/update-readme-docs/SKILL.md @@ -14,7 +14,7 @@ Regenerates the marker-delimited documentation sections in `README.md` on the cu - **Minimum Essentials Framework Versions**: one entry per distinct `MinimumEssentialsFrameworkVersion` in the factories; do not edit by hand or mark it ``, or it stops following version bumps. - **Supported Types**: must match every `TypeNames` entry in the plugin's factory classes (any file name). - **Config Example**: `type` is set by the target to the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; do not change it by hand. The target also removes the unused `uid` property. Must use the real config class(es), JSON property names and value types (nested objects such as `control` are emitted as `"SampleValue"`). Replace `SampleString`/`SampleValue`/`GeneratedKey` placeholders with realistic values, using the `` marker (see below). Take example values from the `` blocks in `MakeModelConfigObject.cs`. - - **Join Maps**: if empty, the generator could not find the join map file (it looks for `.cs`). Write the table by hand from the `JoinDataComplete` definitions and mark the section ``. + - **Join Maps**: generated from classes deriving from `JoinMapBaseAdvanced`, whatever their file name. If a join is missing (for example a `JoinDataComplete` without `JoinNumber` or `JoinType`), write the table by hand from the definitions and mark the section ``. - **Base Classes / Interfaces**: the target lists the base classes (Base Classes) and interfaces (Interfaces) declared by the plugin's device classes, excluding factories and join maps. Verify against `src/`; do not edit by hand unless you add ``. Interfaces uses the generator's `Interfaces Implemented` markers and is empty when there are none. - **Public Methods / Feedbacks**: remove noise (non-public API, base classes of factories, template-only members) by hand and mark ``; for a section that does not apply, keep the markers with only `` between them (deleting the markers does not work, the generator re-adds them). 3. Ensure a blank line precedes each `` marker and the file ends with a newline. diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29..0000000 diff --git a/.husky/commit-msg b/.husky/commit-msg index c331c11..b2a4d6a 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,5 +1,5 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -## Lints the commit message against the Conventional Commits rules enforced in CI +## Lints the commit message against Conventional Commits (rules in .husky/csx/commit-lint.csx) dotnet husky run --group commit-msg --args "$1" diff --git a/LICENSE.md b/LICENSE.md index d67fed7..72f5269 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -Copyright (c) <2020> PepperDash Technology Corporation +Copyright (c) 2020-2026 PepperDash Technology Corporation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index ecd2dbb..4cda242 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![Crestron](https://img.shields.io/badge/Crestron-4--Series-lightgrey) ![License](https://img.shields.io/badge/license-MIT-green) -# Essentials Plugin Template (c) 2025 +# Essentials Plugin Template (c) 2026 ## Overview @@ -147,11 +147,11 @@ In CI (`CI=true`) the build regenerates the sections in memory and fails with `P Then review `git diff README.md` and commit the result with your changes. -The generator is a C# port of `metadata.py` from [PepperDash/workflow-templates](https://github.com/PepperDash/workflow-templates) (used by the older `update-readme` workflow) and produces the same sections, except that the output order no longer depends on the file system. +The generator is a C# port of `metadata.py` from [PepperDash/workflow-templates](https://github.com/PepperDash/workflow-templates) (used by the older `update-readme` workflow) and produces the same sections, with these differences: the output order no longer depends on the file system, each Minimum Essentials Framework Version is listed once, a join map is also found when its file is not named after the class, interface names are matched case-sensitively, and Base Classes are listed before Interfaces as plain items. #### Controlling the Output -* The Config Example `type` is the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`, and the unused `uid` property is removed. Factories, join maps and config classes are found by their content, not their file or class names, so renaming them (for example to `SonyBraviaDeviceFactory`) needs no changes here. +* The Config Example `type` is the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`, and the unused `uid` property is removed. Factories and join maps are found by their content, not their file or class names, so renaming them (for example to `SonyBraviaDeviceFactory`) needs no changes here. The config class is the class whose name ends in `Config` or `ConfigObject` with the most properties. * Base Classes and Interfaces list the base classes and interfaces declared on the plugin's own device classes (factories and join maps are excluded). Each has its own section; Interfaces is empty when no device class declares one. * Sections the generator gets wrong (for example placeholder config values or a join map it can't find) can be edited by hand. Add `` on the line after the `` marker and later runs leave that section alone. * To hide a section that doesn't apply, keep its markers with only `` between them. Deleting the markers doesn't work; the generator adds them back. diff --git a/build/ReadmeDocs.cs b/build/ReadmeDocs.cs index a0aad91..afaac1a 100644 --- a/build/ReadmeDocs.cs +++ b/build/ReadmeDocs.cs @@ -3,9 +3,14 @@ // // Port of metadata.py from PepperDash/workflow-templates (commit 681f68e), followed by the template's // post-processing (Config Example type/uid, Base Classes and Interfaces). Output matches metadata.py -// except that each Minimum Essentials Framework Version is listed once, and that it is deterministic: -// source files are read in sorted folder order and each file's Supported Types keep their declared -// order (metadata.py used os.walk order and an unordered set). +// except that: +// - it is deterministic: source files are read in sorted folder order and each file's Supported Types +// keep their declared order (metadata.py used os.walk order and an unordered set) +// - each Minimum Essentials Framework Version is listed once +// - a join map is also found by its class declaration when its file is not named .cs +// - factories are recognized by their Essentials base class, whatever they are named +// - interfaces are matched case-sensitively (IpTableObjectBase is a base class) +// - Base Classes come before Interfaces, as plain list items // // Must stay compatible with netstandard2.0 / C# 7.3 so it also compiles under Visual Studio's MSBuild. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 60185df..5c50d51 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,7 +8,6 @@ Copyright © 2026 https://github.com/PepperDash/EssentialsPluginTemplate.git git - Crestron; 4series ..\output True LICENSE.md diff --git a/src/MakeModelBridgeJoinMap.cs b/src/MakeModelBridgeJoinMap.cs index 72fee24..8f9ff71 100644 --- a/src/MakeModelBridgeJoinMap.cs +++ b/src/MakeModelBridgeJoinMap.cs @@ -10,7 +10,7 @@ namespace PepperDash.Essentials.Plugin /// /// /// - /// "EssentialsPluginBridgeJoinMapTemplate" renamed to "SamsungMdcBridgeJoinMap" + /// "EssentialsPluginTemplateBridgeJoinMap" renamed to "SamsungMdcBridgeJoinMap" /// public class EssentialsPluginTemplateBridgeJoinMap : JoinMapBaseAdvanced { diff --git a/src/MakeModelConfigObject.cs b/src/MakeModelConfigObject.cs index 0492f38..a8c1dfa 100644 --- a/src/MakeModelConfigObject.cs +++ b/src/MakeModelConfigObject.cs @@ -11,7 +11,7 @@ namespace PepperDash.Essentials.Plugin /// Rename the class to match the device plugin being created /// /// - /// "EssentialsPluginConfigObjectTemplate" renamed to "SamsungMdcConfig" + /// "MakeModelConfig" renamed to "SamsungMdcConfig" /// [ConfigSnippet("\"properties\":{\"control\":{}")] public class MakeModelConfig diff --git a/src/MakeModelCrestronDevice.cs b/src/MakeModelCrestronDevice.cs index 6397168..54cbb4a 100644 --- a/src/MakeModelCrestronDevice.cs +++ b/src/MakeModelCrestronDevice.cs @@ -16,7 +16,7 @@ namespace PepperDash.Essentials.Plugin /// Rename the class to match the device plugin being developed. /// /// - /// "EssentialsPluginDeviceTemplate" renamed to "SamsungMdcDevice" + /// "MakeModelCrestronDevice" renamed to "CrestronTst1080Device" /// public class MakeModelCrestronDevice : CrestronGenericBridgeableBaseDevice { diff --git a/src/MakeModelCrestronDeviceFactory.cs b/src/MakeModelCrestronDeviceFactory.cs index e026892..9182000 100644 --- a/src/MakeModelCrestronDeviceFactory.cs +++ b/src/MakeModelCrestronDeviceFactory.cs @@ -13,7 +13,7 @@ namespace PepperDash.Essentials.Plugin /// Rename the class to match the device plugin being developed /// /// - /// "EssentialsPluginFactoryTemplate" renamed to "MyCrestronDeviceFactory" + /// "MakeModelCrestronDeviceFactory" renamed to "CrestronTst1080DeviceFactory" /// public class MakeModelCrestronDeviceFactory : EssentialsPluginDeviceFactory { @@ -26,11 +26,11 @@ public class MakeModelCrestronDeviceFactory : EssentialsPluginDeviceFactory /// Set the minimum Essentials Framework Version /// - /// MinimumEssentialsFrameworkVersion = "1.6.4; + /// MinimumEssentialsFrameworkVersion = "2.0.0"; /// /// In the constructor we initialize the list with the typenames that will build an instance of this device /// - /// TypeNames = new List() { "SamsungMdc", "SamsungMdcDisplay" }; + /// TypeNames = new List() { "crestronTst1080" }; /// /// public MakeModelCrestronDeviceFactory() @@ -45,7 +45,7 @@ public MakeModelCrestronDeviceFactory() } /// - /// Builds and returns an instance of EssentialsPluginTemplateCrestronDevice + /// Builds and returns an instance of MakeModelCrestronDevice /// /// device configuration /// plugin device or null diff --git a/src/MakeModelDevice.cs b/src/MakeModelDevice.cs index 792679a..14f88d5 100644 --- a/src/MakeModelDevice.cs +++ b/src/MakeModelDevice.cs @@ -17,7 +17,7 @@ namespace PepperDash.Essentials.Plugin /// Rename the class to match the device plugin being developed. /// /// - /// "EssentialsPluginDeviceTemplate" renamed to "SamsungMdcDevice" + /// "MakeModelDevice" renamed to "SamsungMdcDevice" /// public class MakeModelDevice : EssentialsBridgeableDevice { @@ -152,7 +152,7 @@ private void socket_ConnectionChange(object sender, GenericSocketStatusChageEven StatusFeedback?.FireUpdate(); } - // TODO [ ] If not using an API with a delimeter, delete the method below + // TODO [ ] If not using an API with a delimiter, delete the method below private void Handle_LineRecieved(object sender, GenericCommMethodReceiveTextArgs args) { // TODO [ ] Implement method @@ -161,14 +161,14 @@ private void Handle_LineRecieved(object sender, GenericCommMethodReceiveTextArgs receiveQueue.Enqueue(new ProcessStringMessage(args.Text, ProcessFeedbackMessage)); } - // TODO [ ] If not using an HEX/byte based API with no delimeter, delete the method below + // TODO [ ] If not using an HEX/byte based API with no delimiter, delete the method below private void Handle_BytesReceived(object sender, GenericCommMethodReceiveBytesArgs args) { // TODO [ ] Implement method throw new System.NotImplementedException(); } - // TODO [ ] If not using an ASCII based API with no delimeter, delete the method below + // TODO [ ] If not using an ASCII based API with no delimiter, delete the method below void Handle_TextReceived(object sender, GenericCommMethodReceiveTextArgs e) { // TODO [ ] Implement method @@ -184,7 +184,7 @@ void ProcessFeedbackMessage(string message) } - // TODO [ ] If not using an ACII based API, delete the properties below + // TODO [ ] If not using an ASCII based API, delete the properties below /// /// Sends text to the device plugin comms /// diff --git a/src/MakeModelDeviceFactory.cs b/src/MakeModelDeviceFactory.cs index 4a05780..e57b277 100644 --- a/src/MakeModelDeviceFactory.cs +++ b/src/MakeModelDeviceFactory.cs @@ -11,7 +11,7 @@ namespace PepperDash.Essentials.Plugin /// Rename the class to match the device plugin being developed /// /// - /// "EssentialsPluginFactoryTemplate" renamed to "MyDeviceFactory" + /// "MakeModelDeviceFactory" renamed to "SamsungMdcDeviceFactory" /// public class MakeModelDeviceFactory : EssentialsPluginDeviceFactory { @@ -24,7 +24,7 @@ public class MakeModelDeviceFactory : EssentialsPluginDeviceFactory /// Set the minimum Essentials Framework Version /// - /// MinimumEssentialsFrameworkVersion = "2.12.1; + /// MinimumEssentialsFrameworkVersion = "2.0.0"; /// /// In the constructor we initialize the list with the typenames that will build an instance of this device /// @@ -43,7 +43,7 @@ public MakeModelDeviceFactory() } /// - /// Builds and returns an instance of EssentialsPluginDeviceTemplate + /// Builds and returns an instance of MakeModelDevice /// /// device configuration /// plugin device or null @@ -65,7 +65,7 @@ public override EssentialsDevice BuildDevice(PepperDash.Essentials.Core.Config.D } // attempt build the plugin device comms device & check for null - // TODO { ] As of PepperDash Core 1.0.41, HTTP and HTTPS are not valid eControlMethods and will throw an exception. + // TODO [ ] As of PepperDash Core 1.0.41, HTTP and HTTPS are not valid eControlMethods and will throw an exception. var comms = CommFactory.CreateCommForDevice(dc); if (comms == null) { diff --git a/src/MakeModelLogicDevice.cs b/src/MakeModelLogicDevice.cs index 64b2f3b..93d2e9d 100644 --- a/src/MakeModelLogicDevice.cs +++ b/src/MakeModelLogicDevice.cs @@ -13,7 +13,7 @@ namespace PepperDash.Essentials.Plugin /// Rename the class to match the device plugin being developed. /// /// - /// "EssentialsPluginTemplateLogicDevice" renamed to "SamsungMdcDevice" + /// "MakeModelLogicDevice" renamed to "RoomSchedulerLogicDevice" /// public class MakeModelLogicDevice : EssentialsBridgeableDevice { diff --git a/src/MakeModelLogicDeviceFactory.cs b/src/MakeModelLogicDeviceFactory.cs index fee4d47..0d16ac8 100644 --- a/src/MakeModelLogicDeviceFactory.cs +++ b/src/MakeModelLogicDeviceFactory.cs @@ -11,7 +11,7 @@ namespace PepperDash.Essentials.Plugin /// Rename the class to match the device plugin being developed /// /// - /// "EssentialsPluginFactoryTemplate" renamed to "MyLogicDeviceFactory" + /// "MakeModelLogicDeviceFactory" renamed to "RoomSchedulerLogicDeviceFactory" /// public class MakeModelLogicDeviceFactory : EssentialsPluginDeviceFactory { @@ -24,11 +24,11 @@ public class MakeModelLogicDeviceFactory : EssentialsPluginDeviceFactory /// Set the minimum Essentials Framework Version /// - /// MinimumEssentialsFrameworkVersion = "1.6.4; + /// MinimumEssentialsFrameworkVersion = "2.0.0"; /// /// In the constructor we initialize the list with the typenames that will build an instance of this device /// - /// TypeNames = new List() { "SamsungMdc", "SamsungMdcDisplay" }; + /// TypeNames = new List() { "roomScheduler" }; /// /// public MakeModelLogicDeviceFactory() @@ -43,7 +43,7 @@ public MakeModelLogicDeviceFactory() } /// - /// Builds and returns an instance of EssentialsPluginTemplateLogicDevice + /// Builds and returns an instance of MakeModelLogicDevice /// /// device configuration /// plugin device or null diff --git a/src/Properties/AssemblyInfo.cs b/src/Properties/AssemblyInfo.cs deleted file mode 100644 index 275e917..0000000 --- a/src/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("EssentialsPluginTemplateEpi")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("EssentialsPluginTemplateEpi")] -[assembly: AssemblyCopyright("Copyright © 2022")] -[assembly: AssemblyVersion("1.0.0.*")] - diff --git a/src/Properties/ControlSystem.cfg b/src/Properties/ControlSystem.cfg deleted file mode 100644 index e69de29..0000000 diff --git a/src/epi-make-model.4Series.csproj b/src/epi-make-model.4Series.csproj index dae11e7..d762885 100644 --- a/src/epi-make-model.4Series.csproj +++ b/src/epi-make-model.4Series.csproj @@ -2,21 +2,17 @@ ProgramLibrary + net472 PepperDash.Essentials.Plugin false PepperDash.Essentials.Plugin.Make.Model - PepperDash Technology This software is a template for a PepperDash Essentials Plugin. - Copyright 2026 - 1.0.0-local true - $(Version) bin\$(Configuration)\ - PepperDash Technology - Pepperdash.Essentials.Plugins.Template - https://github.com/PepperDash/EssentialsPluginTemplate.git + PepperDash.Essentials.Plugins.Template + https://github.com/PepperDash/EssentialsPluginTemplate crestron 4series essentials plugin template @@ -25,26 +21,12 @@ - - - + - - - - - - runtime - - - - - - - - - - + + runtime + + From 7f5284c22249329e614a949897c14c886daa4489 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 1 Oct 2026 18:12:35 -0500 Subject: [PATCH 5/7] refactor: use MakeModel placeholders and restructure README with Diataxis Placeholders: - Replace the remaining EssentialsPluginTemplate names: the join map class is MakeModelBridgeJoinMap, Product/Description say "Make Model", and the repository URLs use the epi-make-model placeholder - Namespace and RootNamespace are PepperDash.Essentials.Plugins.MakeModel; AssemblyTitle and PackageId are PepperDash.Essentials.Plugins.Make.Model - Rename MakeModelConfigObject.cs to MakeModelPropertiesConfig.cs, with the classes MakeModelPropertiesConfig and MakeModelPropertiesConfigDictionary - Drop "template" from PackageTags README: - Organize the hand-written sections as Tutorial, How-to guides, Reference and Explanation; the tutorial was verified end to end on a scratch clone - Add reference tables for the repository layout, placeholder names, build outputs, targets, properties, PDREADME error codes, badges and generated sections - Update the update-readme-docs skill for the renamed config class Co-Authored-By: Claude Opus 5.5 --- .github/skills/update-readme-docs/SKILL.md | 2 +- README.md | 308 ++++++++++++------ src/Directory.Build.props | 4 +- src/MakeModelBridgeJoinMap.cs | 10 +- src/MakeModelCrestronDevice.cs | 8 +- src/MakeModelCrestronDeviceFactory.cs | 4 +- src/MakeModelDevice.cs | 8 +- src/MakeModelDeviceFactory.cs | 4 +- src/MakeModelLogicDevice.cs | 8 +- src/MakeModelLogicDeviceFactory.cs | 4 +- ...Object.cs => MakeModelPropertiesConfig.cs} | 14 +- src/epi-make-model.4Series.csproj | 12 +- 12 files changed, 255 insertions(+), 131 deletions(-) rename src/{MakeModelConfigObject.cs => MakeModelPropertiesConfig.cs} (92%) diff --git a/.github/skills/update-readme-docs/SKILL.md b/.github/skills/update-readme-docs/SKILL.md index 02da1d3..331e72a 100644 --- a/.github/skills/update-readme-docs/SKILL.md +++ b/.github/skills/update-readme-docs/SKILL.md @@ -13,7 +13,7 @@ Regenerates the marker-delimited documentation sections in `README.md` on the cu 2. Read `git diff README.md` and compare each generated section against the source in `src/`: - **Minimum Essentials Framework Versions**: one entry per distinct `MinimumEssentialsFrameworkVersion` in the factories; do not edit by hand or mark it ``, or it stops following version bumps. - **Supported Types**: must match every `TypeNames` entry in the plugin's factory classes (any file name). - - **Config Example**: `type` is set by the target to the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; do not change it by hand. The target also removes the unused `uid` property. Must use the real config class(es), JSON property names and value types (nested objects such as `control` are emitted as `"SampleValue"`). Replace `SampleString`/`SampleValue`/`GeneratedKey` placeholders with realistic values, using the `` marker (see below). Take example values from the `` blocks in `MakeModelConfigObject.cs`. + - **Config Example**: `type` is set by the target to the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; do not change it by hand. The target also removes the unused `uid` property. Must use the real config class(es), JSON property names and value types (nested objects such as `control` are emitted as `"SampleValue"`). Replace `SampleString`/`SampleValue`/`GeneratedKey` placeholders with realistic values, using the `` marker (see below). Take example values from the `` blocks in the config class (`MakeModelPropertiesConfig.cs` in the template). - **Join Maps**: generated from classes deriving from `JoinMapBaseAdvanced`, whatever their file name. If a join is missing (for example a `JoinDataComplete` without `JoinNumber` or `JoinType`), write the table by hand from the definitions and mark the section ``. - **Base Classes / Interfaces**: the target lists the base classes (Base Classes) and interfaces (Interfaces) declared by the plugin's device classes, excluding factories and join maps. Verify against `src/`; do not edit by hand unless you add ``. Interfaces uses the generator's `Interfaces Implemented` markers and is empty when there are none. - **Public Methods / Feedbacks**: remove noise (non-public API, base classes of factories, template-only members) by hand and mark ``; for a section that does not apply, keep the markers with only `` between them (deleting the markers does not work, the generator re-adds them). diff --git a/README.md b/README.md index 4cda242..abde499 100644 --- a/README.md +++ b/README.md @@ -7,154 +7,278 @@ # Essentials Plugin Template (c) 2026 -## Overview +Use this repository as the starting point for a new [PepperDash Essentials](https://github.com/PepperDash/Essentials) plugin. For background on plugins, see the Essentials Wiki [Plugins](https://pepperdash.github.io/Essentials/docs/Plugins.html) article. -Use this repository as the starting point for a new Essentials plugin. For more information about plugins, refer to the Essentials Wiki [Plugins](https://pepperdash.github.io/Essentials/docs/Plugins.html) article. +This README follows the [Diataxis](https://diataxis.fr/) structure: -This repo contains example classes for the three main categories of devices: +* [Tutorial](#tutorial) - build your first plugin from the template, step by step +* [How-to guides](#how-to-guides) - recipes for specific tasks +* [Reference](#reference) - files, build properties, targets, error codes and rules +* [Explanation](#explanation) - how the template and its automation work, and why +* [Plugin Documentation](#plugin-documentation) - the generated documentation of this plugin -* `MakeModelDevice`: Used for most third party devices which require communication over a streaming mechanism such as a Com port, TCP/SSH/UDP socket, CEC, etc -* `MakeModelLogicDevice`: Used for devices that contain logic, but don't require any communication with third parties outside the program -* `MakeModelCrestronDevice`: Used for devices that represent a piece of Crestron hardware +## Tutorial -There are matching factory classes for each of the three categories of devices. `MakeModelConfigObject` and `MakeModelBridgeJoinMap` are templates that should be modified for whichever categories of device the plugin uses. +In this tutorial you create a plugin for a fictional serial/TCP display, the "Samsung MDC", from the template, build it, document it and commit it. It takes about 15 minutes. You need the [.NET SDK](https://dotnet.microsoft.com/download), Git, and Visual Studio or VS Code. -This also illustrates how a plugin can contain multiple devices. +### 1. Create the repository -## Getting Started +On GitHub, create a new repository from this template (**Use this template**), or fork it. Clone it and open the folder: -1. **Create your repository.** Fork this repository into your own GitHub space, then create a new repository using it as the template. -2. **Build.** Open `epi-make-model.4Series.sln` in Visual Studio or VS Code and build, or run `dotnet build` from the repo root. Dependencies restore automatically (see [Prerequisites](#prerequisites)). -3. **Rename and customize.** Work through the [customization checklist](#renaming-and-customizing-the-template). -4. **Set up releases.** Update `.releaserc.json` (see [Building, Packaging and Releasing](#building-packaging-and-releasing)). -5. **Document the plugin.** Generate the plugin documentation at the end of this README (see [README Docs](#readme-docs-generated-plugin-documentation)). +``` +git clone https://github.com//epi-samsung-mdc.git +cd epi-samsung-mdc +``` -## Prerequisites +### 2. Build it once -* [.NET SDK](https://dotnet.microsoft.com/download) (builds the project and provides `dotnet tool` for the git hooks) -* Visual Studio or VS Code -* The [Essentials](https://github.com/PepperDash/Essentials) libraries are referenced with a NuGet `PackageReference` (`PepperDashEssentials`) in `src/epi-make-model.4Series.csproj`. They are restored automatically by Visual Studio, `dotnet restore` or `dotnet build`; `nuget.exe` is not required. +``` +dotnet build +``` + +The build restores the Essentials NuGet packages, installs the git hooks, and writes two files to `output/`: `epi-make-model.4Series.1.0.0-local.cplz` (the plugin) and a `.nupkg` (the NuGet package). Run `git config core.hooksPath`; it prints `.husky`, which confirms the commit-message hook is active. + +### 3. Keep one device category + +The template has three example devices. A display is controlled over a serial or TCP connection, so keep `MakeModelDevice` and its factory, and delete the other two device classes and their factories: + +``` +git rm src/MakeModelLogicDevice.cs src/MakeModelLogicDeviceFactory.cs +git rm src/MakeModelCrestronDevice.cs src/MakeModelCrestronDeviceFactory.cs +``` + +### 4. Name the device + +1. Rename `MakeModelDevice` to `SamsungMdcDevice` and `MakeModelDeviceFactory` to `SamsungMdcDeviceFactory`, in both the class and the file name. Your editor's rename refactoring updates the references. +2. In `SamsungMdcDeviceFactory`, set the type name that configuration files will use: + + ```csharp + TypeNames = new List() { "samsungMdc" }; + ``` + +3. Build again with `dotnet build`. It succeeds, and the badges at the top of this README are unchanged because the Essentials version did not change. -The git hooks and README docs tooling have their own requirements; see [Repository Automation](#repository-automation). +### 5. Document the plugin -## Renaming and Customizing the Template +``` +dotnet msbuild -t:UpdateReadmeDocs +git diff README.md +``` + +The [Plugin Documentation](#plugin-documentation) sections now list `samsungMdc` under Supported Types, and only the base classes of the device you kept. + +### 6. Commit + +``` +git add -A +git commit -m "feat: add Samsung MDC display plugin" +``` + +The commit-msg hook checks the message and the commit succeeds. Try `git commit --allow-empty -m "added stuff"` to see it reject a message that does not follow the [commit message rules](#commit-message-rules). -There is extensive inline documentation and examples in the source. In Visual Studio, the Task List lists every `TODO [ ]` item to complete. For renaming instructions in particular, see the XML `remarks` tags on the class definitions. +You now have a building, documented plugin. Next, work through [Rename the template](#rename-the-template) to finish customizing it, and [Release a version](#release-a-version) to publish it. -Checklist: +## How-to guides -1. Rename the solution, project, namespace and classes to match the plugin. The template's class and file names are not fully consistent (for example, the join map class is `EssentialsPluginTemplateBridgeJoinMap` in `MakeModelBridgeJoinMap.cs`), so search for both `MakeModel` and `EssentialsPluginTemplate`. +### Rename the template + +Every name to replace is a form of "Make Model"; the [placeholder names](#placeholder-names) table lists each form and where it appears. Search the repository for `MakeModel`, `make-model`, `Make.Model` and `Make Model`, then: + +1. Rename the solution and project files (`epi-make-model.4Series.sln`, `src/epi-make-model.4Series.csproj`, and the project path inside the `.sln`), the namespace and the classes. The XML `remarks` and `example` tags on each class show the intended rename, for example `MakeModelDevice` to `SamsungMdcDevice`. 2. Delete the device categories and factories the plugin does not need. 3. In each factory, set `MinimumEssentialsFrameworkVersion` and `TypeNames`. -4. Set the `PepperDashEssentials` package version in the csproj to `MinimumEssentialsFrameworkVersion` or higher (the build fails if it is lower; see [README Badges](#readme-badges)). -5. Modify `MakeModelConfigObject` and `MakeModelBridgeJoinMap` for the plugin's configuration and joins. -6. Update the package properties in the csproj (see [Package properties](#package-properties)). -7. Update `.releaserc.json` (see [Building, Packaging and Releasing](#building-packaging-and-releasing)). -8. Regenerate the README docs. +4. Update `MakeModelPropertiesConfig` and `MakeModelBridgeJoinMap` for the plugin's configuration and joins. +5. Update the [package properties](#package-properties). +6. Update `.releaserc.json` (see [Release a version](#release-a-version)). +7. [Regenerate the plugin documentation](#regenerate-the-plugin-documentation). -## Building, Packaging and Releasing +In Visual Studio, the Task List shows every remaining `TODO [ ]` item. -Building the project (Visual Studio, or `dotnet build`) produces two artifacts in `output/`: +### Change the minimum Essentials version -* `..cplz` - the plugin, for loading on a processor -* `..nupkg` - the NuGet package, which includes `LICENSE.md` and this `README.md` +1. Set `MinimumEssentialsFrameworkVersion` in each factory to the lowest Essentials version the plugin is tested against. +2. Set the `PepperDashEssentials` `PackageReference` version in the csproj to the same version or higher. +3. Run `dotnet build`. The `PepperDash Essentials` badge updates; commit `README.md` with the change. +4. [Regenerate the plugin documentation](#regenerate-the-plugin-documentation) so the Minimum Essentials Framework Versions section matches. -Because this README is packed into the NuGet package, keep it accurate for plugin users. +### Regenerate the plugin documentation -### Package properties +1. Run `dotnet msbuild -t:UpdateReadmeDocs` from the repo root, or in VS Code run **Terminal > Run Task... > Update README docs**. In Copilot Chat, `/update-readme-docs` runs the target and then reviews the result. +2. Review `git diff README.md`. Placeholder values such as `SampleString` in the Config Example need replacing (see the next guide). +3. Commit `README.md` with your changes. -To modify the name and other details of the package, edit the following properties in `src/epi-make-model.4Series.csproj`: +### Edit a generated section by hand -1. `PackageId` - This is the name that will be used to pull the package from NuGet once it's published -2. `PackageProjectUrl` - This should match the URL for the plugin repo -3. `AssemblyTitle` - This is the DLL file name that will show on a processor when the plugin is loaded +1. Edit the content between the section's `` and `` markers. +2. Add `` on the line after the `` marker so later runs leave the section alone. -Shared values such as `Version`, `Copyright` and `PackageOutputPath` are defined in `src/Directory.Build.props`; the csproj values take precedence where both are set. +To hide a section that does not apply, keep its markers with only `` between them. Deleting the markers does not work; the generator adds them back. -### Releases (PepperDash Internal) +Do not mark Minimum Essentials Framework Versions as ``, or it stops following version changes. -Every push runs `.github/workflows/EssentialsPlugins-builds-caller.yml`, which calls the shared workflows in [PepperDash/workflow-templates](https://github.com/PepperDash/workflow-templates) to determine the version with [semantic-release](https://semantic-release.gitbook.io/) (`.releaserc.json`) and build the plugin when there is a new version. +### Check the README the way CI does -* Versions are derived from commit messages. A commit scope of `force-patch` forces a patch release and `no-release` skips the release. -* `.releaserc.json` ships with placeholder pre-release settings (`replace-me-feature-branch`, `replace-me-prerelease`). Replace them with your pre-release branch and channel, or remove the entry if you do not use one. +``` +dotnet build -p:ReadmeMode=Check +``` -## Repository Automation +The build fails with `PDREADME004` if the badges are stale and with `PDREADME005` if the plugin documentation is stale, and changes nothing. Fix it with `dotnet build` and `dotnet msbuild -t:UpdateReadmeDocs`. -### README Badges +### Release a version -The `.NET` and `PepperDash Essentials` badges at the top of this README are kept in sync by an MSBuild target in `src/Directory.Build.targets`, so no extra tools are needed: +1. Replace the placeholder pre-release entry in `.releaserc.json` (`replace-me-feature-branch`, `replace-me-prerelease`) with your pre-release branch and channel, or remove it if you only release from `main`. +2. Write [conventional commit messages](#commit-message-rules); they determine the next version. +3. Push. The build workflow releases when semantic-release finds a new version. Use the commit scope `force-patch` to force a patch release, or `no-release` to skip one. -* `.NET` badge - `TargetFramework` of the plugin project -* `PepperDash Essentials` badge - the highest `MinimumEssentialsFrameworkVersion = "x.y.z";` assignment in the project's C# files (subfolders included), so factories can be renamed freely (for example `SonyBraviaDeviceFactory`) +### Install or skip the git hooks -Every local `dotnet build` (or Visual Studio build) rewrites the badges when they are stale; commit the `README.md` change with your code. The `PepperDashEssentials` `PackageReference` version in the csproj must be equal to or greater than `MinimumEssentialsFrameworkVersion` (a prerelease such as `2.13.0-beta` counts as lower than `2.13.0`); otherwise the build fails with `PDREADME002`. +* The hooks install automatically on `dotnet restore` or `dotnet build`, and when VS Code runs the "Install git hooks" task on folder open. To install them manually: `dotnet tool restore`, then `dotnet husky install`. +* Skip the hook for one commit: `git commit --no-verify`. +* Skip the automatic install: set the environment variable `HUSKY=0`. -In CI (`CI=true`) the target only checks: a stale badge fails the build with `PDREADME004`, so a release is never packaged with an out-of-date README. +### Package properties -* Check locally without modifying files: `dotnet build -p:ReadmeMode=Check` -* Skip the target: `dotnet build -p:SkipReadmeBadges=true` +Set these plugin-specific properties in the csproj: -Keep a `MinimumEssentialsFrameworkVersion = "x.y.z";` assignment in at least one factory, and keep the two badges in this README. Renaming the project or factory classes does not require changes. +1. `PackageId` - the name used to install the package from NuGet +2. `PackageProjectUrl` - the plugin repository's URL +3. `AssemblyTitle` - the DLL name shown on a processor when the plugin is loaded +4. `Description` and `PackageTags` -### Git Hooks (Husky.Net) +Set `Product` and `RepositoryUrl` in `src/Directory.Build.props`. Shared values (`Version`, `Authors`, `Company`, `Copyright`, `PackageOutputPath`) are also defined there. -This repo uses [Husky.Net](https://alirezanet.github.io/Husky.Net/) for one `commit-msg` hook. It runs `.husky/csx/commit-lint.csx`, which checks the message against [Conventional Commits](https://www.conventionalcommits.org/) using the type list from the shared `checkCommitMessage` workflow in PepperDash/workflow-templates (this template's CI does not currently run that check): +## Reference -* Header: `(): `, at most 100 characters -* Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `revert`, `wip` -* A blank line between the header and the body -* Merge, `Revert "..."`, `fixup!`, `squash!` and `amend!` messages are allowed locally (squash `fixup!` commits before pushing) -* Breaking changes use a `BREAKING CHANGE:` footer; the `feat!:` form is not recognized by the release tooling +### Repository layout -The only requirement is the [.NET SDK](https://dotnet.microsoft.com/download). +| Path | Contents | +| --- | --- | +| `src/MakeModelDevice.cs`, `src/MakeModelDeviceFactory.cs` | Device that talks to third-party equipment over a stream (serial, TCP/SSH/UDP, CEC) | +| `src/MakeModelLogicDevice.cs`, `src/MakeModelLogicDeviceFactory.cs` | Device with logic only, no external communication | +| `src/MakeModelCrestronDevice.cs`, `src/MakeModelCrestronDeviceFactory.cs` | Device that represents Crestron hardware | +| `src/MakeModelPropertiesConfig.cs` | Device configuration class (`MakeModelPropertiesConfig`): the device's `properties` in the configuration file | +| `src/MakeModelBridgeJoinMap.cs` | EISC bridge join map (`MakeModelBridgeJoinMap`) | +| `src/Directory.Build.props` | Shared version, package and copyright properties | +| `src/Directory.Build.targets` | CPLZ packaging, git hook install, README badge and docs targets | +| `build/ReadmeDocs.cs` | Plugin documentation generator, compiled by the build | +| `.husky/` | Commit-msg hook and its linter (`csx/commit-lint.csx`) | +| `.releaserc.json` | semantic-release configuration | +| `.github/workflows/EssentialsPlugins-builds-caller.yml` | Build and release workflow | +| `.github/skills/update-readme-docs/` | Copilot skill that regenerates and reviews the plugin documentation | + +### Placeholder names + +The template names everything after a fictional "Make Model" device. Replace each form with your plugin's make and model; for a Samsung MDC display, `MakeModel` becomes `SamsungMdc` and `epi-make-model` becomes `epi-samsung-mdc`. + +| Form | Where | +| --- | --- | +| `MakeModel` | Class and file names in `src/` (devices, factories, `MakeModelPropertiesConfig`, `MakeModelBridgeJoinMap`), and the namespace `PepperDash.Essentials.Plugins.MakeModel` (`RootNamespace` in the csproj and every `.cs` file) | +| `epi-make-model` | Solution and project file names, the GitHub repository name in `RepositoryUrl` (`src/Directory.Build.props`) and `PackageProjectUrl` (csproj) | +| `Make.Model` | `AssemblyTitle` and `PackageId` (both `PepperDash.Essentials.Plugins.Make.Model`) in the csproj | +| `Make Model` | `Product` (`src/Directory.Build.props`) and `Description` (csproj) | +| `examplePlugin…` | `TypeNames` in each factory, the configuration `type` values | -#### Setup +### Build outputs -The hook is installed automatically the first time you do any of the following in a fresh clone: +| File | Description | +| --- | --- | +| `output/..cplz` | The plugin, for loading on a processor | +| `output/..nupkg` | The NuGet package, including `LICENSE.md` and this `README.md` | -* Open the solution in Visual Studio, or run `dotnet restore` / `dotnet build` -* Open the folder in VS Code and allow the "Install git hooks" automatic task +### Build targets -To install it manually: +| Target | Runs | Effect | +| --- | --- | --- | +| `UpdateReadmeBadges` | Before every local build | Rewrites stale `.NET` and `PepperDash Essentials` badges. Incremental. | +| `CheckReadmeBadges` | Before the build when `ReadmeMode` is `Check` | Fails on stale badges | +| `UpdateReadmeDocs` | Only when invoked (`dotnet msbuild -t:UpdateReadmeDocs`) | Regenerates the [Plugin Documentation](#plugin-documentation) sections | +| `CheckReadmeDocs` | Before the build when `ReadmeMode` is `Check` | Fails on stale plugin documentation | +| `InstallGitHooks` | Before restore, unless `CI=true` or `HUSKY=0` | Runs `dotnet tool restore` and `dotnet husky install` | -``` -dotnet tool restore -dotnet husky install -``` +The README targets run only for projects with `ProjectType` `ProgramLibrary`, and never in design-time builds. -Verify with `git config core.hooksPath`, which should print `.husky`. +### Build properties -#### Usage +| Property | Default | Effect | +| --- | --- | --- | +| `ReadmeMode` | `Check` when `CI=true`, otherwise `Update` | `Check` verifies the README without changing it | +| `SkipReadmeBadges` | not set | `true` skips the badge targets | +| `SkipReadmeDocsCheck` | not set | `true` skips `CheckReadmeDocs` | +| `ReadmePath` | `README.md` at the repo root | README file the targets update | +| `HUSKY` | not set | `0` skips the git hook install (environment variable) | -* Commit as usual; the hook runs on every `git commit`. -* Skip the hook for a single commit: `git commit --no-verify` -* Skip the automatic install on restore: set the `HUSKY=0` environment variable. It is also skipped when `CI=true`. +### Error codes -### README Docs (Generated Plugin Documentation) +| Code | Meaning | Fix | +| --- | --- | --- | +| `PDREADME001` | No `MinimumEssentialsFrameworkVersion = "x.y.z";` assignment found in the project's C# files | Set it in at least one factory | +| `PDREADME002` | The `PepperDashEssentials` package version is lower than `MinimumEssentialsFrameworkVersion` (a prerelease such as `2.13.0-beta` counts as lower than `2.13.0`) | Raise the package version or lower the factory minimum | +| `PDREADME003` | A badge is missing from `README.md` | Restore the `.NET` and `PepperDash Essentials` badges at the top | +| `PDREADME004` | The badges are stale (check mode) | Run `dotnet build` and commit `README.md` | +| `PDREADME005` | The plugin documentation is stale (check mode) | Run `dotnet msbuild -t:UpdateReadmeDocs`, review and commit `README.md` | -The plugin's config example, supported types, join maps, feedbacks and public methods are generated into `` / `` sections under [Plugin Documentation](#plugin-documentation) at the end of this README. Generation is an MSBuild target (`UpdateReadmeDocs` in `src/Directory.Build.targets`, implemented in `build/ReadmeDocs.cs`) that you run on your branch, review, and commit with your changes. It needs only the .NET SDK and runs offline. +### Badges -In CI (`CI=true`) the build regenerates the sections in memory and fails with `PDREADME005` if they differ from the committed README, so a release is never packaged with out-of-date docs. Skip that check with `-p:SkipReadmeDocsCheck=true`. +| Badge | Source | +| --- | --- | +| `.NET` | `TargetFramework` of the plugin project | +| `PepperDash Essentials` | The highest `MinimumEssentialsFrameworkVersion = "x.y.z";` assignment in the project's C# files, in any file and subfolder | -#### Running the Update +### Generated documentation sections -| Where | How | +| Section | Source | | --- | --- | -| VS Code | `Terminal > Run Task...` > **Update README docs** | -| Terminal (repo root) | `dotnet msbuild -t:UpdateReadmeDocs` | -| Check only, no changes | `dotnet build -p:ReadmeMode=Check` | -| Copilot Chat | `/update-readme-docs` - runs the target, then reviews the generated sections against `src/` and cleans them up | +| Minimum Essentials Framework Versions | Each distinct `MinimumEssentialsFrameworkVersion` value | +| Config Example | The class whose name ends in `Config` or `ConfigObject` with the most properties. `type` is the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; the unused `uid` property is removed. | +| Supported Types | Every `TypeNames` entry | +| Join Maps | Classes deriving from `JoinMapBaseAdvanced`, in any file | +| Base Classes, Interfaces | Base classes and interfaces declared by the plugin's own classes, excluding factories and join maps | +| Public Methods | Public methods in the project's C# files | +| Bool, Int and String Feedbacks | Public `BoolFeedback`, `IntFeedback` and `StringFeedback` members | + +A section containing `` is never changed. Factories and join maps are found by their content, so renaming them (for example to `SonyBraviaDeviceFactory`) needs no changes. + +### Commit message rules + +* Header: `(): `, at most 100 characters +* Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `revert`, `wip` +* A blank line between the header and the body +* Breaking changes: a `BREAKING CHANGE:` footer. The `feat!:` form is rejected because the release tooling does not recognize it. +* Allowed as-is: merge commits, `Revert "..."`, and `fixup!`, `squash!` and `amend!` commits (squash these before pushing) + +The type list follows the shared `checkCommitMessage` workflow in PepperDash/workflow-templates. This template's build workflow does not run that check. + +### Requirements + +* [.NET SDK](https://dotnet.microsoft.com/download): building, the git hooks and all README automation. Nothing else is required. +* Visual Studio or VS Code +* The Essentials libraries come from the `PepperDashEssentials` NuGet package and restore automatically; `nuget.exe` is not required. + +## Explanation + +### Device categories + +Essentials plugins usually wrap one of three kinds of device, so the template includes one example of each: a device that talks to third-party equipment over a stream (`MakeModelDevice`), a device that only contains logic (`MakeModelLogicDevice`), and a device that represents Crestron hardware (`MakeModelCrestronDevice`). Each has its own factory, which tells Essentials which configuration `type` values create it and which Essentials version it needs. One plugin can contain several devices. + +### Why the README is maintained by the build + +This README is packed into the plugin's NuGet package, so the badges and the plugin documentation must match the code that was released. Both are produced by MSBuild targets rather than by a git hook or a CI job: + +* A build target runs the same way in Visual Studio, VS Code, the command line and CI, and needs nothing beyond the .NET SDK. +* Git hooks can be skipped, and a CI job that commits to the repository creates extra commits after review. Generating locally means the change is reviewed with the code that caused it. +* CI runs the same targets in check mode, so a release cannot be packaged with a README that no longer matches the code. + +The badges are rewritten on every build because they are purely mechanical. The plugin documentation is regenerated only when you ask for it, because it needs a review: the Config Example contains placeholder values, and some sections are curated by hand with ``. + +CI checks the README only in the release build, which runs when semantic-release finds a new version. Ordinary pushes are not checked. -Then review `git diff README.md` and commit the result with your changes. +### The documentation generator -The generator is a C# port of `metadata.py` from [PepperDash/workflow-templates](https://github.com/PepperDash/workflow-templates) (used by the older `update-readme` workflow) and produces the same sections, with these differences: the output order no longer depends on the file system, each Minimum Essentials Framework Version is listed once, a join map is also found when its file is not named after the class, interface names are matched case-sensitively, and Base Classes are listed before Interfaces as plain items. +`build/ReadmeDocs.cs` is a C# port of `metadata.py` from [PepperDash/workflow-templates](https://github.com/PepperDash/workflow-templates), which the older `update-readme` workflow runs in CI. It produces the same sections, with these differences: the output order no longer depends on the file system, each Minimum Essentials Framework Version is listed once, a join map is also found when its file is not named after the class, interface names are matched case-sensitively, and Base Classes are listed before Interfaces as plain items. The two implementations are maintained separately. -#### Controlling the Output +### Commit messages and versions -* The Config Example `type` is the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`, and the unused `uid` property is removed. Factories and join maps are found by their content, not their file or class names, so renaming them (for example to `SonyBraviaDeviceFactory`) needs no changes here. The config class is the class whose name ends in `Config` or `ConfigObject` with the most properties. -* Base Classes and Interfaces list the base classes and interfaces declared on the plugin's own device classes (factories and join maps are excluded). Each has its own section; Interfaces is empty when no device class declares one. -* Sections the generator gets wrong (for example placeholder config values or a join map it can't find) can be edited by hand. Add `` on the line after the `` marker and later runs leave that section alone. -* To hide a section that doesn't apply, keep its markers with only `` between them. Deleting the markers doesn't work; the generator adds them back. +Releases are versioned by [semantic-release](https://semantic-release.gitbook.io/), which reads the commit messages since the last release: `fix` produces a patch release, `feat` a minor release, and a `BREAKING CHANGE:` footer a major release. The commit-msg hook catches malformed messages before they reach the shared history, where they would be ignored or produce the wrong version. ## License @@ -162,7 +286,7 @@ Provided under the MIT license; see [LICENSE.md](LICENSE.md). ## Plugin Documentation -The sections below are generated; see [README Docs](#readme-docs-generated-plugin-documentation). +The sections below are generated from the source code; see [Regenerate the plugin documentation](#regenerate-the-plugin-documentation) and [Generated documentation sections](#generated-documentation-sections). ### Minimum Essentials Framework Versions diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5c50d51..7b71b6a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,9 +4,9 @@ $(Version) PepperDash Technology PepperDash Technology - PepperDash Essentials Plugin Template + PepperDash Essentials Plugin Make Model Copyright © 2026 - https://github.com/PepperDash/EssentialsPluginTemplate.git + https://github.com/PepperDash/epi-make-model.git git ..\output True diff --git a/src/MakeModelBridgeJoinMap.cs b/src/MakeModelBridgeJoinMap.cs index 8f9ff71..295a00a 100644 --- a/src/MakeModelBridgeJoinMap.cs +++ b/src/MakeModelBridgeJoinMap.cs @@ -1,6 +1,6 @@ using PepperDash.Essentials.Core; -namespace PepperDash.Essentials.Plugin +namespace PepperDash.Essentials.Plugins.MakeModel { /// /// Plugin device Bridge Join Map @@ -10,9 +10,9 @@ namespace PepperDash.Essentials.Plugin /// /// /// - /// "EssentialsPluginTemplateBridgeJoinMap" renamed to "SamsungMdcBridgeJoinMap" + /// "MakeModelBridgeJoinMap" renamed to "SamsungMdcBridgeJoinMap" /// - public class EssentialsPluginTemplateBridgeJoinMap : JoinMapBaseAdvanced + public class MakeModelBridgeJoinMap : JoinMapBaseAdvanced { #region Digital @@ -93,8 +93,8 @@ public class EssentialsPluginTemplateBridgeJoinMap : JoinMapBaseAdvanced /// Plugin device BridgeJoinMap constructor /// /// This will be the join it starts on the EISC bridge - public EssentialsPluginTemplateBridgeJoinMap(uint joinStart) - : base(joinStart, typeof(EssentialsPluginTemplateBridgeJoinMap)) + public MakeModelBridgeJoinMap(uint joinStart) + : base(joinStart, typeof(MakeModelBridgeJoinMap)) { } } diff --git a/src/MakeModelCrestronDevice.cs b/src/MakeModelCrestronDevice.cs index 54cbb4a..c688b9f 100644 --- a/src/MakeModelCrestronDevice.cs +++ b/src/MakeModelCrestronDevice.cs @@ -7,7 +7,7 @@ using PepperDash.Essentials.Core; using PepperDash.Essentials.Core.Bridges; -namespace PepperDash.Essentials.Plugin +namespace PepperDash.Essentials.Plugins.MakeModel { /// /// Plugin device @@ -23,7 +23,7 @@ public class MakeModelCrestronDevice : CrestronGenericBridgeableBaseDevice /// /// It is often desirable to store the config /// - private readonly MakeModelConfig config; + private readonly MakeModelPropertiesConfig config; #region Constructor for Devices without IBasicCommunication. Remove if not needed @@ -34,7 +34,7 @@ public class MakeModelCrestronDevice : CrestronGenericBridgeableBaseDevice /// /// /// - public MakeModelCrestronDevice(string key, string name, MakeModelConfig config, GenericBase hardware) + public MakeModelCrestronDevice(string key, string name, MakeModelPropertiesConfig config, GenericBase hardware) : base(key, name, hardware) { this.LogInformation("Constructing new {0} instance", name); @@ -60,7 +60,7 @@ public MakeModelCrestronDevice(string key, string name, MakeModelConfig config, /// public override void LinkToApi(BasicTriList trilist, uint joinStart, string joinMapKey, EiscApiAdvanced bridge) { - var joinMap = new EssentialsPluginTemplateBridgeJoinMap(joinStart); + var joinMap = new MakeModelBridgeJoinMap(joinStart); // This adds the join map to the collection on the bridge bridge?.AddJoinMap(Key, joinMap); diff --git a/src/MakeModelCrestronDeviceFactory.cs b/src/MakeModelCrestronDeviceFactory.cs index 9182000..7dd4476 100644 --- a/src/MakeModelCrestronDeviceFactory.cs +++ b/src/MakeModelCrestronDeviceFactory.cs @@ -3,7 +3,7 @@ using PepperDash.Core; using PepperDash.Essentials.Core; -namespace PepperDash.Essentials.Plugin +namespace PepperDash.Essentials.Plugins.MakeModel { /// @@ -60,7 +60,7 @@ public override EssentialsDevice BuildDevice(PepperDash.Essentials.Core.Config.D Debug.LogDebug("[{key}] Factory Attempting to create new device from type: {type}", dc.Key, dc.Type); // get the plugin device properties configuration object & check for null - var propertiesConfig = dc.Properties.ToObject(); + var propertiesConfig = dc.Properties.ToObject(); if (propertiesConfig == null) { Debug.LogWarning("[{key}] Factory: failed to read properties config for {name}", dc.Key, dc.Name); diff --git a/src/MakeModelDevice.cs b/src/MakeModelDevice.cs index 14f88d5..b6199e0 100644 --- a/src/MakeModelDevice.cs +++ b/src/MakeModelDevice.cs @@ -8,7 +8,7 @@ using PepperDash.Essentials.Core.Bridges; using PepperDash.Essentials.Core.Queues; -namespace PepperDash.Essentials.Plugin +namespace PepperDash.Essentials.Plugins.MakeModel { /// /// Plugin device template for third party devices that use IBasicCommunication @@ -24,7 +24,7 @@ public class MakeModelDevice : EssentialsBridgeableDevice /// /// It is often desirable to store the config /// - private readonly MakeModelConfig config; + private readonly MakeModelPropertiesConfig config; /// /// Provides a queue and dedicated worker thread for processing feedback messages from a device. @@ -98,7 +98,7 @@ public bool Connect /// /// /// - public MakeModelDevice(string key, string name, MakeModelConfig config, IBasicCommunication comms) + public MakeModelDevice(string key, string name, MakeModelPropertiesConfig config, IBasicCommunication comms) : base(key, name) { this.LogInformation("Constructing new {0} instance", name); @@ -241,7 +241,7 @@ public void Poll() /// public override void LinkToApi(BasicTriList trilist, uint joinStart, string joinMapKey, EiscApiAdvanced bridge) { - var joinMap = new EssentialsPluginTemplateBridgeJoinMap(joinStart); + var joinMap = new MakeModelBridgeJoinMap(joinStart); // This adds the join map to the collection on the bridge bridge?.AddJoinMap(Key, joinMap); diff --git a/src/MakeModelDeviceFactory.cs b/src/MakeModelDeviceFactory.cs index e57b277..957025d 100644 --- a/src/MakeModelDeviceFactory.cs +++ b/src/MakeModelDeviceFactory.cs @@ -2,7 +2,7 @@ using PepperDash.Core; using PepperDash.Essentials.Core; -namespace PepperDash.Essentials.Plugin +namespace PepperDash.Essentials.Plugins.MakeModel { /// /// Plugin device factory for devices that use IBasicCommunication @@ -57,7 +57,7 @@ public override EssentialsDevice BuildDevice(PepperDash.Essentials.Core.Config.D Debug.LogVerbose("[{key}] Factory Attempting to create new device from type: {type}", dc.Key, dc.Type); // get the plugin device properties configuration object & check for null - var propertiesConfig = dc.Properties.ToObject(); + var propertiesConfig = dc.Properties.ToObject(); if (propertiesConfig == null) { Debug.LogError("[{key}] Factory: failed to read properties config for {name}", dc.Key, dc.Name); diff --git a/src/MakeModelLogicDevice.cs b/src/MakeModelLogicDevice.cs index 93d2e9d..5df3c60 100644 --- a/src/MakeModelLogicDevice.cs +++ b/src/MakeModelLogicDevice.cs @@ -4,7 +4,7 @@ using PepperDash.Essentials.Core; using PepperDash.Essentials.Core.Bridges; -namespace PepperDash.Essentials.Plugin +namespace PepperDash.Essentials.Plugins.MakeModel { /// /// Plugin device template for logic devices that don't communicate outside the program @@ -20,7 +20,7 @@ public class MakeModelLogicDevice : EssentialsBridgeableDevice /// /// It is often desirable to store the config /// - private readonly MakeModelConfig config; + private readonly MakeModelPropertiesConfig config; /// /// Plugin device constructor @@ -28,7 +28,7 @@ public class MakeModelLogicDevice : EssentialsBridgeableDevice /// /// /// - public MakeModelLogicDevice(string key, string name, MakeModelConfig config) + public MakeModelLogicDevice(string key, string name, MakeModelPropertiesConfig config) : base(key, name) { this.LogInformation("Constructing new {0} instance", name); @@ -49,7 +49,7 @@ public MakeModelLogicDevice(string key, string name, MakeModelConfig config) /// public override void LinkToApi(BasicTriList trilist, uint joinStart, string joinMapKey, EiscApiAdvanced bridge) { - var joinMap = new EssentialsPluginTemplateBridgeJoinMap(joinStart); + var joinMap = new MakeModelBridgeJoinMap(joinStart); // This adds the join map to the collection on the bridge bridge?.AddJoinMap(Key, joinMap); diff --git a/src/MakeModelLogicDeviceFactory.cs b/src/MakeModelLogicDeviceFactory.cs index 0d16ac8..f2db3c6 100644 --- a/src/MakeModelLogicDeviceFactory.cs +++ b/src/MakeModelLogicDeviceFactory.cs @@ -2,7 +2,7 @@ using PepperDash.Core; using PepperDash.Essentials.Core; -namespace PepperDash.Essentials.Plugin +namespace PepperDash.Essentials.Plugins.MakeModel { /// /// Plugin device factory for logic devices that don't communicate @@ -58,7 +58,7 @@ public override EssentialsDevice BuildDevice(PepperDash.Essentials.Core.Config.D Debug.LogDebug("[{key}] Factory Attempting to create new device from type: {type}", dc.Key, dc.Type); // get the plugin device properties configuration object & check for null - var propertiesConfig = dc.Properties.ToObject(); + var propertiesConfig = dc.Properties.ToObject(); if (propertiesConfig == null) { Debug.LogError("[{key}] Factory: failed to read properties config for {name}", dc.Key, dc.Name); diff --git a/src/MakeModelConfigObject.cs b/src/MakeModelPropertiesConfig.cs similarity index 92% rename from src/MakeModelConfigObject.cs rename to src/MakeModelPropertiesConfig.cs index a8c1dfa..6ab57d8 100644 --- a/src/MakeModelConfigObject.cs +++ b/src/MakeModelPropertiesConfig.cs @@ -2,7 +2,7 @@ using Newtonsoft.Json; using PepperDash.Essentials.Core; -namespace PepperDash.Essentials.Plugin +namespace PepperDash.Essentials.Plugins.MakeModel { /// /// Plugin device configuration object @@ -11,10 +11,10 @@ namespace PepperDash.Essentials.Plugin /// Rename the class to match the device plugin being created /// /// - /// "MakeModelConfig" renamed to "SamsungMdcConfig" + /// "MakeModelPropertiesConfig" renamed to "SamsungMdcPropertiesConfig" /// [ConfigSnippet("\"properties\":{\"control\":{}")] - public class MakeModelConfig + public class MakeModelPropertiesConfig { /// /// JSON control object @@ -139,7 +139,7 @@ public class MakeModelConfig /// /// [JsonProperty("DeviceDictionary")] - public Dictionary DeviceDictionary { get; set; } + public Dictionary DeviceDictionary { get; set; } /// /// Constuctor @@ -148,9 +148,9 @@ public class MakeModelConfig /// If using a collection you must instantiate the collection in the constructor /// to avoid exceptions when reading the configuration file /// - public MakeModelConfig() + public MakeModelPropertiesConfig() { - DeviceDictionary = new Dictionary(); + DeviceDictionary = new Dictionary(); } } @@ -172,7 +172,7 @@ public MakeModelConfig() /// } /// /// - public class MakeModelConfigDictionary + public class MakeModelPropertiesConfigDictionary { /// /// Serializes collection name property diff --git a/src/epi-make-model.4Series.csproj b/src/epi-make-model.4Series.csproj index d762885..3753264 100644 --- a/src/epi-make-model.4Series.csproj +++ b/src/epi-make-model.4Series.csproj @@ -5,15 +5,15 @@ net472 - PepperDash.Essentials.Plugin + PepperDash.Essentials.Plugins.MakeModel false - PepperDash.Essentials.Plugin.Make.Model - This software is a template for a PepperDash Essentials Plugin. + PepperDash.Essentials.Plugins.Make.Model + This software is a PepperDash Essentials Plugin for a Make Model device. true bin\$(Configuration)\ - PepperDash.Essentials.Plugins.Template - https://github.com/PepperDash/EssentialsPluginTemplate - crestron 4series essentials plugin template + PepperDash.Essentials.Plugins.Make.Model + https://github.com/PepperDash/epi-make-model + crestron 4series essentials plugin From d3f7ae280778518b738d4541be75e87064349216 Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 1 Oct 2026 18:20:01 -0500 Subject: [PATCH 6/7] fix: list all minimum versions and join access in README generator - Collect every MinimumEssentialsFrameworkVersion in a file (Matches), not just the first - Render join access as R, W or R/W from JoinCapabilities instead of always R - Document that SKIP still lets the Config Example's type and uid be updated Co-Authored-By: Claude Opus 5.5 --- .github/skills/update-readme-docs/SKILL.md | 2 +- README.md | 6 ++-- build/ReadmeDocs.cs | 32 ++++++++++++++++------ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/skills/update-readme-docs/SKILL.md b/.github/skills/update-readme-docs/SKILL.md index 331e72a..112887c 100644 --- a/.github/skills/update-readme-docs/SKILL.md +++ b/.github/skills/update-readme-docs/SKILL.md @@ -23,5 +23,5 @@ Regenerates the marker-delimited documentation sections in `README.md` on the cu ## Rules -- `` inside a section makes the generator leave it untouched on later runs. Use it only for sections you curated by hand, and say so in the summary. +- `` inside a section makes the generator leave it untouched on later runs, except that the Config Example's `type` and `uid` are still updated. Use it only for sections you curated by hand, and say so in the summary. - Never fabricate joins, feedbacks, or config properties; derive everything from `src/`. diff --git a/README.md b/README.md index abde499..ae377c7 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ In Visual Studio, the Task List shows every remaining `TODO [ ]` item. ### Edit a generated section by hand 1. Edit the content between the section's `` and `` markers. -2. Add `` on the line after the `` marker so later runs leave the section alone. +2. Add `` on the line after the `` marker so later runs leave the section alone. The Config Example is the exception: its `type` and `uid` are always updated (see [Generated documentation sections](#generated-documentation-sections)). To hide a section that does not apply, keep its markers with only `` between them. Deleting the markers does not work; the generator adds them back. @@ -231,12 +231,12 @@ The README targets run only for projects with `ProjectType` `ProgramLibrary`, an | Minimum Essentials Framework Versions | Each distinct `MinimumEssentialsFrameworkVersion` value | | Config Example | The class whose name ends in `Config` or `ConfigObject` with the most properties. `type` is the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; the unused `uid` property is removed. | | Supported Types | Every `TypeNames` entry | -| Join Maps | Classes deriving from `JoinMapBaseAdvanced`, in any file | +| Join Maps | Classes deriving from `JoinMapBaseAdvanced`, in any file. Type (RW) is `R`, `W` or `R/W` from each join's `JoinCapabilities` | | Base Classes, Interfaces | Base classes and interfaces declared by the plugin's own classes, excluding factories and join maps | | Public Methods | Public methods in the project's C# files | | Bool, Int and String Feedbacks | Public `BoolFeedback`, `IntFeedback` and `StringFeedback` members | -A section containing `` is never changed. Factories and join maps are found by their content, so renaming them (for example to `SonyBraviaDeviceFactory`) needs no changes. +A section containing `` is never changed, except that the Config Example's `type` and `uid` are still updated as described above. Factories and join maps are found by their content, so renaming them (for example to `SonyBraviaDeviceFactory`) needs no changes. ### Commit message rules diff --git a/build/ReadmeDocs.cs b/build/ReadmeDocs.cs index afaac1a..8b25835 100644 --- a/build/ReadmeDocs.cs +++ b/build/ReadmeDocs.cs @@ -6,7 +6,8 @@ // except that: // - it is deterministic: source files are read in sorted folder order and each file's Supported Types // keep their declared order (metadata.py used os.walk order and an unordered set) -// - each Minimum Essentials Framework Version is listed once +// - each distinct Minimum Essentials Framework Version is listed once, including several in one file +// - join access (R, W or R/W) follows JoinCapabilities (metadata.py listed every join as R) // - a join map is also found by its class declaration when its file is not named .cs // - factories are recognized by their Essentials base class, whatever they are named // - interfaces are matched case-sensitively (IpTableObjectBase is a base class) @@ -123,9 +124,9 @@ public static string Generate(string readme, IList sources, Action FindJoinMapClasses(IList sources) @@ -277,7 +279,7 @@ private static IEnumerable ParseJoinMap(string className, IList ParseJoinMap(string className, IList 0 && number != null && type != null) - yield return new JoinInfo { Number = number, Type = type, Description = description }; + yield return new JoinInfo { Number = number, Type = type, Description = description, Access = access }; else log("incomplete join information for '" + joinName + "'; skipping"); } } + // ToSIMPL is feedback SIMPL reads (R), FromSIMPL is a command SIMPL writes (W); null for None + private static string JoinAccess(string capabilities) + { + var read = Regex.IsMatch(capabilities, @"\b(?:ToSIMPL|ToFromSIMPL)\b"); + var write = Regex.IsMatch(capabilities, @"\b(?:FromSIMPL|ToFromSIMPL)\b"); + if (read && write) return "R/W"; + if (read) return "R"; + if (write) return "W"; + return null; + } + private static string JoinMapChart(List joins) { if (joins.Count == 0) return ""; @@ -314,7 +329,7 @@ private static string JoinMapChart(List joins) sb.Append("| Join | Type (RW) | Description |\n"); sb.Append("| --- | --- | --- |\n"); foreach (var j in ofKind) - sb.Append("| ").Append(j.Number).Append(" | R | ").Append(j.Description ?? "None").Append(" |\n"); + sb.Append("| ").Append(j.Number).Append(" | ").Append(j.Access).Append(" | ").Append(j.Description ?? "None").Append(" |\n"); sb.Append('\n'); } return sb.ToString(); @@ -500,7 +515,8 @@ private static string FindConfigType(List byName) return null; } - // Applied even to sections: the unused "uid" is removed and "type" is set + // Applied even to sections, so "type" always follows the factories: the unused "uid" + // is removed and "type" is set private static string FixConfigExample(string readme, string configType) { var section = Regex.Match(readme, @"(?s).*?"); From d08957f2bef38b1143547a1febbe1aca350a271e Mon Sep 17 00:00:00 2001 From: jkdevito Date: Thu, 1 Oct 2026 19:35:37 -0500 Subject: [PATCH 7/7] feat: always generate the README Config Example from config examples - Fill each property from the first block containing its JSON name - Default pollTimeMs, warningTimeoutMs and errorTimeoutMs to 30000, 180000 and 300000 - Use realistic key, name and group values - Ignore in the Config Example so it always follows the code - Fix the template config examples (pollTimeMs, control, DeviceDictionary) - Document setting Config Example values in README and the update-readme-docs skill Co-Authored-By: Claude Opus 5.5 --- .github/skills/update-readme-docs/SKILL.md | 4 +- README.md | 40 +++-- build/ReadmeDocs.cs | 199 +++++++++++++++++++-- src/MakeModelPropertiesConfig.cs | 49 +++-- 4 files changed, 240 insertions(+), 52 deletions(-) diff --git a/.github/skills/update-readme-docs/SKILL.md b/.github/skills/update-readme-docs/SKILL.md index 112887c..c326d11 100644 --- a/.github/skills/update-readme-docs/SKILL.md +++ b/.github/skills/update-readme-docs/SKILL.md @@ -13,7 +13,7 @@ Regenerates the marker-delimited documentation sections in `README.md` on the cu 2. Read `git diff README.md` and compare each generated section against the source in `src/`: - **Minimum Essentials Framework Versions**: one entry per distinct `MinimumEssentialsFrameworkVersion` in the factories; do not edit by hand or mark it ``, or it stops following version bumps. - **Supported Types**: must match every `TypeNames` entry in the plugin's factory classes (any file name). - - **Config Example**: `type` is set by the target to the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; do not change it by hand. The target also removes the unused `uid` property. Must use the real config class(es), JSON property names and value types (nested objects such as `control` are emitted as `"SampleValue"`). Replace `SampleString`/`SampleValue`/`GeneratedKey` placeholders with realistic values, using the `` marker (see below). Take example values from the `` blocks in the config class (`MakeModelPropertiesConfig.cs` in the template). + - **Config Example**: `type` is set by the target to the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; do not change it by hand. The target also removes the unused `uid` property. Always regenerated: do not edit it by hand or mark it `` (the marker is ignored). Each property's value comes from the first `` block on that property that contains its JSON name; `pollTimeMs`, `warningTimeoutMs` and `errorTimeoutMs` default to `30000`, `180000` and `300000`. If a property still shows `SampleString`/`SampleValue`, add or fix the `` block on it in the config class (`MakeModelPropertiesConfig.cs` in the template), using the JSON name exactly, then run the target again. - **Join Maps**: generated from classes deriving from `JoinMapBaseAdvanced`, whatever their file name. If a join is missing (for example a `JoinDataComplete` without `JoinNumber` or `JoinType`), write the table by hand from the definitions and mark the section ``. - **Base Classes / Interfaces**: the target lists the base classes (Base Classes) and interfaces (Interfaces) declared by the plugin's device classes, excluding factories and join maps. Verify against `src/`; do not edit by hand unless you add ``. Interfaces uses the generator's `Interfaces Implemented` markers and is empty when there are none. - **Public Methods / Feedbacks**: remove noise (non-public API, base classes of factories, template-only members) by hand and mark ``; for a section that does not apply, keep the markers with only `` between them (deleting the markers does not work, the generator re-adds them). @@ -23,5 +23,5 @@ Regenerates the marker-delimited documentation sections in `README.md` on the cu ## Rules -- `` inside a section makes the generator leave it untouched on later runs, except that the Config Example's `type` and `uid` are still updated. Use it only for sections you curated by hand, and say so in the summary. +- `` inside a section makes the generator leave it untouched on later runs, except the Config Example, which is always regenerated. Use it only for sections you curated by hand, and say so in the summary. - Never fabricate joins, feedbacks, or config properties; derive everything from `src/`. diff --git a/README.md b/README.md index ae377c7..1953e9b 100644 --- a/README.md +++ b/README.md @@ -104,17 +104,35 @@ In Visual Studio, the Task List shows every remaining `TODO [ ]` item. ### Regenerate the plugin documentation 1. Run `dotnet msbuild -t:UpdateReadmeDocs` from the repo root, or in VS Code run **Terminal > Run Task... > Update README docs**. In Copilot Chat, `/update-readme-docs` runs the target and then reviews the result. -2. Review `git diff README.md`. Placeholder values such as `SampleString` in the Config Example need replacing (see the next guide). +2. Review `git diff README.md`. If the Config Example shows placeholders such as `SampleString` or `SampleValue`, add an `` block for that property in the config class (see [Set the Config Example values](#set-the-config-example-values)) and run the target again. 3. Commit `README.md` with your changes. ### Edit a generated section by hand 1. Edit the content between the section's `` and `` markers. -2. Add `` on the line after the `` marker so later runs leave the section alone. The Config Example is the exception: its `type` and `uid` are always updated (see [Generated documentation sections](#generated-documentation-sections)). +2. Add `` on the line after the `` marker so later runs leave the section alone. To hide a section that does not apply, keep its markers with only `` between them. Deleting the markers does not work; the generator adds them back. -Do not mark Minimum Essentials Framework Versions as ``, or it stops following version changes. +Do not mark Minimum Essentials Framework Versions as ``, or it stops following version changes. The Config Example is always regenerated and ignores ``; set its values in the config class instead. + +### Set the Config Example values + +The Config Example takes each property's value from the first `` block on that property whose `` contains the property's JSON name, for example: + +```csharp +/// +/// +/// "properties": { +/// "pollTimeMs": 60000 +/// } +/// +/// +[JsonProperty("pollTimeMs")] +public long PollTimeMs { get; set; } +``` + +Without an example, `pollTimeMs`, `warningTimeoutMs` and `errorTimeoutMs` default to `30000`, `180000` and `300000`, and other properties get placeholders from their C# type. Run `dotnet msbuild -t:UpdateReadmeDocs` after changing an example. ### Check the README the way CI does @@ -229,14 +247,14 @@ The README targets run only for projects with `ProjectType` `ProgramLibrary`, an | Section | Source | | --- | --- | | Minimum Essentials Framework Versions | Each distinct `MinimumEssentialsFrameworkVersion` value | -| Config Example | The class whose name ends in `Config` or `ConfigObject` with the most properties. `type` is the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; the unused `uid` property is removed. | +| Config Example | The class whose name ends in `Config` or `ConfigObject` with the most properties. Values come from each property's `` block (see [Set the Config Example values](#set-the-config-example-values)). `type` is the first `TypeNames` entry of the first C# file (by file name) that sets `TypeNames`; the unused `uid` property is removed. Always regenerated, even with ``. | | Supported Types | Every `TypeNames` entry | | Join Maps | Classes deriving from `JoinMapBaseAdvanced`, in any file. Type (RW) is `R`, `W` or `R/W` from each join's `JoinCapabilities` | | Base Classes, Interfaces | Base classes and interfaces declared by the plugin's own classes, excluding factories and join maps | | Public Methods | Public methods in the project's C# files | | Bool, Int and String Feedbacks | Public `BoolFeedback`, `IntFeedback` and `StringFeedback` members | -A section containing `` is never changed, except that the Config Example's `type` and `uid` are still updated as described above. Factories and join maps are found by their content, so renaming them (for example to `SonyBraviaDeviceFactory`) needs no changes. +A section containing `` is never changed, except the Config Example, which is always regenerated. Factories and join maps are found by their content, so renaming them (for example to `SonyBraviaDeviceFactory`) needs no changes. ### Commit message rules @@ -268,7 +286,7 @@ This README is packed into the plugin's NuGet package, so the badges and the plu * Git hooks can be skipped, and a CI job that commits to the repository creates extra commits after review. Generating locally means the change is reviewed with the code that caused it. * CI runs the same targets in check mode, so a release cannot be packaged with a README that no longer matches the code. -The badges are rewritten on every build because they are purely mechanical. The plugin documentation is regenerated only when you ask for it, because it needs a review: the Config Example contains placeholder values, and some sections are curated by hand with ``. +The badges are rewritten on every build because they are purely mechanical. The plugin documentation is regenerated only when you ask for it, because it needs a review: the Config Example can contain placeholders for properties without an `` block, and some sections are curated by hand with ``. CI checks the README only in the release build, which runs when semantic-release finds a new version. Ordinary pushes are not checked. @@ -295,7 +313,6 @@ The sections below are generated from the source code; see [Regenerate the plugi - ### Config Example ```json @@ -339,7 +356,6 @@ The sections below are generated from the source code; see [Regenerate the plugi - ### Join Maps #### Digitals @@ -354,12 +370,6 @@ The sections below are generated from the source code; see [Regenerate the plugi | Join | Type (RW) | Description | | --- | --- | --- | | 1 | R | Socket Status | - -#### Serials - -| Join | Type (RW) | Description | -| --- | --- | --- | -| 1 | R | Device Name | @@ -394,5 +404,5 @@ The sections below are generated from the source code; see [Regenerate the plugi - + diff --git a/build/ReadmeDocs.cs b/build/ReadmeDocs.cs index 8b25835..71cfc62 100644 --- a/build/ReadmeDocs.cs +++ b/build/ReadmeDocs.cs @@ -7,6 +7,9 @@ // - it is deterministic: source files are read in sorted folder order and each file's Supported Types // keep their declared order (metadata.py used os.walk order and an unordered set) // - each distinct Minimum Essentials Framework Version is listed once, including several in one file +// - the Config Example is always regenerated, even with ; a property's value comes from the +// first block on it that contains its JSON name (pollTimeMs, warningTimeoutMs and +// errorTimeoutMs default to 30000, 180000 and 300000), and key/name/group are realistic // - join access (R, W or R/W) follows JoinCapabilities (metadata.py listed every join as R) // - a join map is also found by its class declaration when its file is not named .cs // - factories are recognized by their Essentials base class, whatever they are named @@ -154,7 +157,7 @@ public static string Generate(string readme, IList sources, Action 0) readme = UpdateSection(readme, "Config Example", configExample); + if (configExample.Length > 0) readme = UpdateSection(readme, "Config Example", configExample, honorSkip: false); readme = UpdateSection(readme, "Supported Types", MarkdownList(supportedTypes, "Supported Types")); readme = UpdateSection(readme, "Join Maps", JoinMapChart(joins)); // Base Classes and Interfaces Implemented bodies are replaced by the post-processing below @@ -352,6 +355,8 @@ private class PropertyDef { public string JsonName; public string Type; + /// Value from the property's <example> block, or null to generate one from Type. + public object Example; } private class ClassDefs @@ -385,10 +390,12 @@ private static ClassDefs ParseAllClasses(IList sources) foreach (Match prop in PropertyPattern.Matches(body)) { var json = JsonPropertyPattern.Match(prop.Value); + var jsonName = json.Success ? json.Groups[1].Value : prop.Groups[2].Value; properties.Add(new PropertyDef { - JsonName = json.Success ? json.Groups[1].Value : prop.Groups[2].Value, + JsonName = jsonName, Type = prop.Groups[1].Value.Trim(), + Example = ExampleValue(DocComment(body, prop.Index), jsonName), }); } defs[classMatch.Groups[1].Value] = properties; @@ -397,6 +404,46 @@ private static ClassDefs ParseAllClasses(IList sources) return defs; } + private static readonly Regex ExampleCodePattern = new Regex(@"(?:(?!).)*?(.*?)", RegexOptions.Singleline); + + // The /// lines directly above a member, without the slashes + private static string DocComment(string body, int memberIndex) + { + var lines = body.Substring(0, memberIndex).Split('\n'); + var doc = new List(); + for (var i = lines.Length - 1; i >= 0; i--) + { + var line = lines[i].Trim(); + if (line.StartsWith("///", StringComparison.Ordinal)) doc.Insert(0, line.Substring(3)); + else if (line.Length > 0 || doc.Count > 0) break; + } + return string.Join("\n", doc); + } + + // The value of jsonName in the first block that contains it, e.g. + // "control": { ... } or "properties": { "pollTimeMs": 30000 } + private static object ExampleValue(string doc, string jsonName) + { + foreach (Match m in ExampleCodePattern.Matches(doc)) + { + object parsed; + if (!Json.TryParse("{" + m.Groups[1].Value + "}", out parsed)) continue; + var queue = new Queue(); + queue.Enqueue(parsed); + while (queue.Count > 0) + { + var obj = queue.Dequeue() as Json.Object; + if (obj == null) continue; + foreach (var item in obj.Items) + { + if (item.Key == jsonName) return item.Value; + queue.Enqueue(item.Value); + } + } + } + return null; + } + private static string ClassBody(string content, int start) { var depth = 1; @@ -415,15 +462,23 @@ private static Json.Object SampleConfig(string configClass, ClassDefs defs, List var typeName = configClass.Substring(0, Math.Max(0, configClass.Length - 6)); if (!supportedTypes.Contains(typeName) && supportedTypes.Count > 0) typeName = supportedTypes[0]; var config = new Json.Object(); - config.Set("key", "GeneratedKey"); + config.Set("key", "device-1"); config.Set("uid", 1); - config.Set("name", "GeneratedName"); + config.Set("name", "Example Device"); config.Set("type", typeName); - config.Set("group", "Group"); + config.Set("group", "pluginDevices"); config.Set("properties", SampleValue(configClass, defs, new HashSet())); return config; } + // Values used when a property with one of these JSON names has no value of its own + private static readonly Dictionary DefaultValues = new Dictionary + { + { "pollTimeMs", new Json.Number("30000") }, + { "warningTimeoutMs", new Json.Number("180000") }, + { "errorTimeoutMs", new Json.Number("300000") }, + }; + private static readonly string[] CollectionPrefixes = { "List<", "IList<", "IEnumerable<", "ObservableCollection<" }; private static object SampleValue(string propertyType, ClassDefs defs, HashSet processing) @@ -452,7 +507,12 @@ private static object SampleValue(string propertyType, ClassDefs defs, HashSet"; var end = ""; var match = Regex.Match(readme, Regex.Escape(start) + "(.*?)" + Regex.Escape(end), RegexOptions.Singleline | RegexOptions.IgnoreCase); if (match.Success) { - if (match.Groups[1].Value.Contains("")) return readme; + if (honorSkip && match.Groups[1].Value.Contains("")) return readme; return readme.Substring(0, match.Index) + start + "\n" + content.TrimEnd() + "\n" + end + readme.Substring(match.Index + match.Length); } if (!readme.EndsWith("\n", StringComparison.Ordinal)) readme += "\n"; @@ -515,8 +575,7 @@ private static string FindConfigType(List byName) return null; } - // Applied even to sections, so "type" always follows the factories: the unused "uid" - // is removed and "type" is set + // The unused "uid" is removed and "type" follows the factories private static string FixConfigExample(string readme, string configType) { var section = Regex.Match(readme, @"(?s).*?"); @@ -623,9 +682,129 @@ public static string Write(object value, int level) } if (value is bool) return (bool)value ? "true" : "false"; if (value is int) return ((int)value).ToString(System.Globalization.CultureInfo.InvariantCulture); + if (value is Number) return ((Number)value).Text; + if (value == null) return "null"; return Quote((string)value); } + /// A number parsed from an example, written back exactly as it was written. + public class Number + { + public Number(string text) { Text = text; } + public string Text { get; private set; } + } + + /// Lenient JSON reader for <example> blocks: allows trailing commas. + public static bool TryParse(string text, out object value) + { + var index = 0; + try + { + value = ParseValue(text, ref index); + SkipWhitespace(text, ref index); + return index == text.Length; + } + catch (FormatException) + { + value = null; + return false; + } + } + + private static object ParseValue(string text, ref int index) + { + SkipWhitespace(text, ref index); + if (index >= text.Length) throw new FormatException(); + var c = text[index]; + if (c == '{') + { + index++; + var obj = new Object(); + while (true) + { + SkipWhitespace(text, ref index); + if (index < text.Length && text[index] == '}') { index++; return obj; } + var key = ParseString(text, ref index); + SkipWhitespace(text, ref index); + Expect(text, ref index, ':'); + obj.Set(key, ParseValue(text, ref index)); + if (!NextItem(text, ref index, '}')) return obj; + } + } + if (c == '[') + { + index++; + var list = new List(); + while (true) + { + SkipWhitespace(text, ref index); + if (index < text.Length && text[index] == ']') { index++; return list; } + list.Add(ParseValue(text, ref index)); + if (!NextItem(text, ref index, ']')) return list; + } + } + if (c == '"') return ParseString(text, ref index); + var literal = Regex.Match(text.Substring(index), @"^(?:true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)"); + if (!literal.Success) throw new FormatException(); + index += literal.Length; + switch (literal.Value) + { + case "true": return true; + case "false": return false; + case "null": return null; + default: return new Number(literal.Value); + } + } + + // After an item: true when another follows, false when the container closed + private static bool NextItem(string text, ref int index, char close) + { + SkipWhitespace(text, ref index); + if (index < text.Length && text[index] == ',') { index++; return true; } + Expect(text, ref index, close); + return false; + } + + private static string ParseString(string text, ref int index) + { + Expect(text, ref index, '"'); + var sb = new StringBuilder(); + while (index < text.Length && text[index] != '"') + { + var c = text[index++]; + if (c != '\\') { sb.Append(c); continue; } + if (index >= text.Length) throw new FormatException(); + var e = text[index++]; + switch (e) + { + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + case 'u': + if (index + 4 > text.Length) throw new FormatException(); + sb.Append((char)Convert.ToInt32(text.Substring(index, 4), 16)); + index += 4; + break; + default: sb.Append(e); break; + } + } + Expect(text, ref index, '"'); + return sb.ToString(); + } + + private static void Expect(string text, ref int index, char c) + { + if (index >= text.Length || text[index] != c) throw new FormatException(); + index++; + } + + private static void SkipWhitespace(string text, ref int index) + { + while (index < text.Length && char.IsWhiteSpace(text[index])) index++; + } + // ensure_ascii=True escaping private static string Quote(string s) { diff --git a/src/MakeModelPropertiesConfig.cs b/src/MakeModelPropertiesConfig.cs index 6ab57d8..4e30205 100644 --- a/src/MakeModelPropertiesConfig.cs +++ b/src/MakeModelPropertiesConfig.cs @@ -25,9 +25,26 @@ public class MakeModelPropertiesConfig /// In order to do so, you will need the username and password in the "tcpSshProperties" object. /// /// + /// TCP/IP (used for the README Config Example) /// /// "control": { /// "method": "tcpIp", + /// "tcpSshProperties": { + /// "address": "172.22.0.101", + /// "port": 23, + /// "username": "admin", + /// "password": "password", + /// "autoReconnect": true, + /// "autoReconnectIntervalMs": 10000 + /// } + /// } + /// + /// + /// + /// RS-232 on a processor COM port + /// + /// "control": { + /// "method": "com", /// "controlPortDevKey": "processor", /// "controlPortNumber": 1, /// "comParams": { @@ -38,14 +55,6 @@ public class MakeModelPropertiesConfig /// "protocol": "RS232", /// "hardwareHandshake": "None", /// "softwareHandshake": "None" - /// }, - /// "tcpSshProperties": { - /// "address": "172.22.0.101", - /// "port": 23, - /// "username": "admin", - /// "password": "password", - /// "autoReconnect": true, - /// "autoReconnectIntervalMs": 10000 /// } /// } /// @@ -65,7 +74,7 @@ public class MakeModelPropertiesConfig /// /// /// "properties": { - /// "polltimeMs": 30000 + /// "pollTimeMs": 30000 /// } /// /// @@ -119,25 +128,15 @@ public class MakeModelPropertiesConfig /// /// /// "properties": { - /// "presets": { - /// "preset1": { - /// "enabled": true, - /// "name": "Preset 1" + /// "DeviceDictionary": { + /// "item1": { + /// "name": "Item 1 Name", + /// "value": 1 /// } /// } /// } /// /// - /// - /// - /// "properties": { - /// "inputNames": { - /// "input1": "Input 1", - /// "input2": "Input 2" - /// } - /// } - /// - /// [JsonProperty("DeviceDictionary")] public Dictionary DeviceDictionary { get; set; } @@ -163,10 +162,10 @@ public MakeModelPropertiesConfig() /// /// /// "properties": { - /// "dictionary": { + /// "DeviceDictionary": { /// "item1": { /// "name": "Item 1 Name", - /// "value": "Item 1 Value" + /// "value": 1 /// } /// } /// }