├── .gitattributes ├── .gitignore ├── FanTrayIcon ├── FanTrayIcon.csproj ├── TrayIcon.cs └── htfancontrol.ico ├── HTFanControl.sln ├── HTFanControl ├── Controllers │ ├── HTTPController.cs │ ├── IController.cs │ ├── LIRCController.cs │ └── MQTTController.cs ├── HTFanControl.csproj ├── Main │ ├── HTFanControl.cs │ └── WebUI.cs ├── Players │ ├── AudioSync.cs │ ├── IPlayer.cs │ ├── KodiPlayer.cs │ ├── MPCPlayer.cs │ ├── PlexPlayer.cs │ ├── RokuPlexPlayer.cs │ └── ZidooPlayer.cs ├── Program.cs ├── Properties │ └── PublishProfiles │ │ ├── linux.pubxml │ │ ├── raspi.pubxml │ │ ├── raspi64.pubxml │ │ └── win.pubxml ├── Timers │ └── PositionTimer.cs ├── Util │ ├── ConfigHelper.cs │ ├── Log.cs │ ├── Settings.cs │ └── WinRegistry.cs ├── htfancontrol.ico └── html │ ├── add.html │ ├── checkupdate.html │ ├── crashlogs.html │ ├── download.html │ ├── downloadlist.html │ ├── edit.html │ ├── fantester.html │ ├── loadedwindtrack.html │ ├── logviewer.html │ ├── manage.html │ ├── selectaudiodevice.html │ ├── selectplexplayer.html │ ├── selectvideo.html │ ├── settings.html │ └── status.html ├── Readme.md └── install ├── HTFanControl.service ├── install.sh ├── uninstall.sh └── update.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | [Aa][Rr][Mm]/ 24 | [Aa][Rr][Mm]64/ 25 | bld/ 26 | [Bb]in/ 27 | [Oo]bj/ 28 | [Ll]og/ 29 | 30 | # Visual Studio 2015/2017 cache/options directory 31 | .vs/ 32 | # Uncomment if you have tasks that create the project's static files in wwwroot 33 | #wwwroot/ 34 | 35 | # Visual Studio 2017 auto generated files 36 | Generated\ Files/ 37 | 38 | # MSTest test Results 39 | [Tt]est[Rr]esult*/ 40 | [Bb]uild[Ll]og.* 41 | 42 | # NUNIT 43 | *.VisualState.xml 44 | TestResult.xml 45 | 46 | # Build Results of an ATL Project 47 | [Dd]ebugPS/ 48 | [Rr]eleasePS/ 49 | dlldata.c 50 | 51 | # Benchmark Results 52 | BenchmarkDotNet.Artifacts/ 53 | 54 | # .NET Core 55 | project.lock.json 56 | project.fragment.lock.json 57 | artifacts/ 58 | 59 | # StyleCop 60 | StyleCopReport.xml 61 | 62 | # Files built by Visual Studio 63 | *_i.c 64 | *_p.c 65 | *_h.h 66 | *.ilk 67 | *.meta 68 | *.obj 69 | *.iobj 70 | *.pch 71 | *.pdb 72 | *.ipdb 73 | *.pgc 74 | *.pgd 75 | *.rsp 76 | *.sbr 77 | *.tlb 78 | *.tli 79 | *.tlh 80 | *.tmp 81 | *.tmp_proj 82 | *_wpftmp.csproj 83 | *.log 84 | *.vspscc 85 | *.vssscc 86 | .builds 87 | *.pidb 88 | *.svclog 89 | *.scc 90 | 91 | # Chutzpah Test files 92 | _Chutzpah* 93 | 94 | # Visual C++ cache files 95 | ipch/ 96 | *.aps 97 | *.ncb 98 | *.opendb 99 | *.opensdf 100 | *.sdf 101 | *.cachefile 102 | *.VC.db 103 | *.VC.VC.opendb 104 | 105 | # Visual Studio profiler 106 | *.psess 107 | *.vsp 108 | *.vspx 109 | *.sap 110 | 111 | # Visual Studio Trace Files 112 | *.e2e 113 | 114 | # TFS 2012 Local Workspace 115 | $tf/ 116 | 117 | # Guidance Automation Toolkit 118 | *.gpState 119 | 120 | # ReSharper is a .NET coding add-in 121 | _ReSharper*/ 122 | *.[Rr]e[Ss]harper 123 | *.DotSettings.user 124 | 125 | # JustCode is a .NET coding add-in 126 | .JustCode 127 | 128 | # TeamCity is a build add-in 129 | _TeamCity* 130 | 131 | # DotCover is a Code Coverage Tool 132 | *.dotCover 133 | 134 | # AxoCover is a Code Coverage Tool 135 | .axoCover/* 136 | !.axoCover/settings.json 137 | 138 | # Visual Studio code coverage results 139 | *.coverage 140 | *.coveragexml 141 | 142 | # NCrunch 143 | _NCrunch_* 144 | .*crunch*.local.xml 145 | nCrunchTemp_* 146 | 147 | # MightyMoose 148 | *.mm.* 149 | AutoTest.Net/ 150 | 151 | # Web workbench (sass) 152 | .sass-cache/ 153 | 154 | # Installshield output folder 155 | [Ee]xpress/ 156 | 157 | # DocProject is a documentation generator add-in 158 | DocProject/buildhelp/ 159 | DocProject/Help/*.HxT 160 | DocProject/Help/*.HxC 161 | DocProject/Help/*.hhc 162 | DocProject/Help/*.hhk 163 | DocProject/Help/*.hhp 164 | DocProject/Help/Html2 165 | DocProject/Help/html 166 | 167 | # Click-Once directory 168 | publish/ 169 | 170 | # Publish Web Output 171 | *.[Pp]ublish.xml 172 | *.azurePubxml 173 | # Note: Comment the next line if you want to checkin your web deploy settings, 174 | # but database connection strings (with potential passwords) will be unencrypted 175 | *.publishproj 176 | 177 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 178 | # checkin your Azure Web App publish settings, but sensitive information contained 179 | # in these scripts will be unencrypted 180 | PublishScripts/ 181 | 182 | # NuGet Packages 183 | *.nupkg 184 | # The packages folder can be ignored because of Package Restore 185 | **/[Pp]ackages/* 186 | # except build/, which is used as an MSBuild target. 187 | !**/[Pp]ackages/build/ 188 | # Uncomment if necessary however generally it will be regenerated when needed 189 | #!**/[Pp]ackages/repositories.config 190 | # NuGet v3's project.json files produces more ignorable files 191 | *.nuget.props 192 | *.nuget.targets 193 | 194 | # Microsoft Azure Build Output 195 | csx/ 196 | *.build.csdef 197 | 198 | # Microsoft Azure Emulator 199 | ecf/ 200 | rcf/ 201 | 202 | # Windows Store app package directories and files 203 | AppPackages/ 204 | BundleArtifacts/ 205 | Package.StoreAssociation.xml 206 | _pkginfo.txt 207 | *.appx 208 | 209 | # Visual Studio cache files 210 | # files ending in .cache can be ignored 211 | *.[Cc]ache 212 | # but keep track of directories ending in .cache 213 | !?*.[Cc]ache/ 214 | 215 | # Others 216 | ClientBin/ 217 | ~$* 218 | *~ 219 | *.dbmdl 220 | *.dbproj.schemaview 221 | *.jfm 222 | *.pfx 223 | *.publishsettings 224 | orleans.codegen.cs 225 | 226 | # Including strong name files can present a security risk 227 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 228 | #*.snk 229 | 230 | # Since there are multiple workflows, uncomment next line to ignore bower_components 231 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 232 | #bower_components/ 233 | 234 | # RIA/Silverlight projects 235 | Generated_Code/ 236 | 237 | # Backup & report files from converting an old project file 238 | # to a newer Visual Studio version. Backup files are not needed, 239 | # because we have git ;-) 240 | _UpgradeReport_Files/ 241 | Backup*/ 242 | UpgradeLog*.XML 243 | UpgradeLog*.htm 244 | ServiceFabricBackup/ 245 | *.rptproj.bak 246 | 247 | # SQL Server files 248 | *.mdf 249 | *.ldf 250 | *.ndf 251 | 252 | # Business Intelligence projects 253 | *.rdl.data 254 | *.bim.layout 255 | *.bim_*.settings 256 | *.rptproj.rsuser 257 | *- Backup*.rdl 258 | 259 | # Microsoft Fakes 260 | FakesAssemblies/ 261 | 262 | # GhostDoc plugin setting file 263 | *.GhostDoc.xml 264 | 265 | # Node.js Tools for Visual Studio 266 | .ntvs_analysis.dat 267 | node_modules/ 268 | 269 | # Visual Studio 6 build log 270 | *.plg 271 | 272 | # Visual Studio 6 workspace options file 273 | *.opt 274 | 275 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 276 | *.vbw 277 | 278 | # Visual Studio LightSwitch build output 279 | **/*.HTMLClient/GeneratedArtifacts 280 | **/*.DesktopClient/GeneratedArtifacts 281 | **/*.DesktopClient/ModelManifest.xml 282 | **/*.Server/GeneratedArtifacts 283 | **/*.Server/ModelManifest.xml 284 | _Pvt_Extensions 285 | 286 | # Paket dependency manager 287 | .paket/paket.exe 288 | paket-files/ 289 | 290 | # FAKE - F# Make 291 | .fake/ 292 | 293 | # JetBrains Rider 294 | .idea/ 295 | *.sln.iml 296 | 297 | # CodeRush personal settings 298 | .cr/personal 299 | 300 | # Python Tools for Visual Studio (PTVS) 301 | __pycache__/ 302 | *.pyc 303 | 304 | # Cake - Uncomment if you are using it 305 | # tools/** 306 | # !tools/packages.config 307 | 308 | # Tabs Studio 309 | *.tss 310 | 311 | # Telerik's JustMock configuration file 312 | *.jmconfig 313 | 314 | # BizTalk build output 315 | *.btp.cs 316 | *.btm.cs 317 | *.odx.cs 318 | *.xsd.cs 319 | 320 | # OpenCover UI analysis results 321 | OpenCover/ 322 | 323 | # Azure Stream Analytics local run output 324 | ASALocalRun/ 325 | 326 | # MSBuild Binary and Structured Log 327 | *.binlog 328 | 329 | # NVidia Nsight GPU debugger configuration file 330 | *.nvuser 331 | 332 | # MFractors (Xamarin productivity tool) working folder 333 | .mfractor/ 334 | 335 | # Local History for Visual Studio 336 | .localhistory/ 337 | 338 | # BeatPulse healthcheck temp database 339 | healthchecksdb -------------------------------------------------------------------------------- /FanTrayIcon/FanTrayIcon.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net5.0-windows 5 | true 6 | true 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /FanTrayIcon/TrayIcon.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Drawing; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Net.NetworkInformation; 7 | using System.Net.Sockets; 8 | using System.Runtime.InteropServices; 9 | using System.Windows.Forms; 10 | using Microsoft.Win32; 11 | 12 | namespace FanTrayIcon 13 | { 14 | public class TrayIcon 15 | { 16 | string _port; 17 | string _instanceName; 18 | 19 | private NotifyIcon trayIcon; 20 | private ToolStripMenuItem itemConsole; 21 | private ToolStripMenuItem itemAutostart; 22 | 23 | [DllImport("kernel32.dll")] 24 | static extern IntPtr GetConsoleWindow(); 25 | 26 | [DllImport("user32.dll")] 27 | static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); 28 | 29 | const int SW_HIDE = 0; 30 | const int SW_SHOW = 5; 31 | 32 | private static string IP 33 | { 34 | get 35 | { 36 | string IP = "IPError"; 37 | foreach (NetworkInterface item in NetworkInterface.GetAllNetworkInterfaces()) 38 | { 39 | if ((item.NetworkInterfaceType == NetworkInterfaceType.Ethernet || item.NetworkInterfaceType == NetworkInterfaceType.Wireless80211) && 40 | !item.Description.ToLower().Contains("virtual") && !item.Name.ToLower().Contains("virtual") && item.OperationalStatus == OperationalStatus.Up) 41 | { 42 | foreach (UnicastIPAddressInformation ip in item.GetIPProperties().UnicastAddresses) 43 | { 44 | if (ip.Address.AddressFamily == AddressFamily.InterNetwork && !ip.Address.ToString().StartsWith("127")) 45 | { 46 | IP = ip.Address.ToString(); 47 | } 48 | } 49 | } 50 | } 51 | return IP; 52 | } 53 | } 54 | 55 | public TrayIcon(string port, string instanceName) 56 | { 57 | _port = port; 58 | _instanceName = instanceName; 59 | 60 | IntPtr handle = GetConsoleWindow(); 61 | ShowWindow(handle, SW_HIDE); 62 | 63 | trayIcon = new NotifyIcon(); 64 | trayIcon.Text = $"{_instanceName} (Right Click For Menu)"; 65 | trayIcon.Icon = new Icon(GetType(), "htfancontrol.ico"); 66 | trayIcon.MouseClick += new MouseEventHandler(trayIcon_MouseClick); 67 | trayIcon.DoubleClick += new EventHandler(trayIcon_DoubleClick); 68 | 69 | ToolStripMenuItem itemWebUI = new ToolStripMenuItem(); 70 | itemWebUI.Text = "Open Web UI"; 71 | itemWebUI.Click += new EventHandler(itemWebUI_Click); 72 | 73 | itemConsole = new ToolStripMenuItem(); 74 | itemConsole.Text = "Show Console Window (Log)"; 75 | itemConsole.CheckOnClick = true; 76 | itemConsole.Click += new EventHandler(itemConsole_Click); 77 | 78 | itemAutostart = new ToolStripMenuItem(); 79 | itemAutostart.Text = $"Start {_instanceName} Automatically"; 80 | itemAutostart.CheckOnClick = true; 81 | itemAutostart.CheckedChanged += new EventHandler(itemAutostart_CheckedChanged); 82 | 83 | if (CheckRegKey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", _instanceName)) 84 | { 85 | itemAutostart.Checked = true; 86 | } 87 | 88 | ToolStripSeparator sep1 = new ToolStripSeparator(); 89 | 90 | ToolStripMenuItem itemClose = new ToolStripMenuItem(); 91 | itemClose.Text = $"Shutdown {_instanceName}"; 92 | itemClose.Click += new EventHandler(itemClose_Click); 93 | 94 | ContextMenuStrip trayMenu = new ContextMenuStrip(); 95 | trayMenu.Items.Add(itemWebUI); 96 | trayMenu.Items.Add(itemConsole); 97 | trayMenu.Items.Add(itemAutostart); 98 | trayMenu.Items.Add(sep1); 99 | trayMenu.Items.Add(itemClose); 100 | 101 | trayIcon.ContextMenuStrip = trayMenu; 102 | trayIcon.Visible = true; 103 | 104 | Application.Run(); 105 | } 106 | 107 | private void trayIcon_MouseClick(object sender, MouseEventArgs e) 108 | { 109 | Point position = Cursor.Position; 110 | position.X -= 253; 111 | position.Y -= 100; 112 | 113 | if (e.Button == MouseButtons.Right) 114 | { 115 | trayIcon.ContextMenuStrip.Show(position); 116 | } 117 | } 118 | 119 | private void trayIcon_DoubleClick(object sender, EventArgs e) 120 | { 121 | trayIcon.ContextMenuStrip.Hide(); 122 | itemWebUI_Click(sender, e); 123 | } 124 | 125 | private void itemWebUI_Click(object sender, EventArgs e) 126 | { 127 | string url = $"http://{IP}:{_port}"; 128 | 129 | try 130 | { 131 | Process.Start(url); 132 | } 133 | catch 134 | { 135 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 136 | { 137 | url = url.Replace("&", "^&"); 138 | Process.Start(new ProcessStartInfo("cmd", $"/c start {url}") { CreateNoWindow = true }); 139 | } 140 | else 141 | { 142 | throw; 143 | } 144 | } 145 | } 146 | 147 | private void itemConsole_Click(object sender, EventArgs e) 148 | { 149 | IntPtr handle = GetConsoleWindow(); 150 | 151 | if (itemConsole.Checked) 152 | { 153 | ShowWindow(handle, SW_SHOW); 154 | } 155 | else 156 | { 157 | ShowWindow(handle, SW_HIDE); 158 | } 159 | } 160 | 161 | private void itemAutostart_CheckedChanged(object sender, EventArgs e) 162 | { 163 | if (itemAutostart.Checked) 164 | { 165 | RegistryKey key = Registry.CurrentUser.OpenSubKey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", true); 166 | if (_port == "5500") 167 | { 168 | key.SetValue("HTFanControl", Process.GetCurrentProcess().MainModule.FileName); 169 | } 170 | else 171 | { 172 | key.SetValue(_instanceName, $@"{Process.GetCurrentProcess().MainModule.FileName} ""{_port}"" ""{_instanceName}"""); 173 | } 174 | } 175 | else 176 | { 177 | RegistryKey key = Registry.CurrentUser.OpenSubKey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", true); 178 | key.DeleteValue(_instanceName, false); 179 | } 180 | } 181 | 182 | private void itemClose_Click(object sender, EventArgs e) 183 | { 184 | trayIcon.Dispose(); 185 | 186 | try 187 | { 188 | DirectoryInfo tmp = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName), "tmp")); 189 | foreach (FileInfo file in tmp.GetFiles()) 190 | { 191 | file.Delete(); 192 | } 193 | } 194 | catch { } 195 | 196 | Environment.Exit(0); 197 | } 198 | 199 | public static bool CheckRegKey(string path, string key) 200 | { 201 | try 202 | { 203 | RegistryKey regKey = Registry.CurrentUser.OpenSubKey(path, true); 204 | return (regKey.GetValueNames().Contains(key)); 205 | } 206 | catch 207 | { 208 | return false; 209 | } 210 | } 211 | } 212 | } -------------------------------------------------------------------------------- /FanTrayIcon/htfancontrol.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicko88/HTFanControl/25954d8da52ba5fb215527427987ee9df501009a/FanTrayIcon/htfancontrol.ico -------------------------------------------------------------------------------- /HTFanControl.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30104.148 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HTFanControl", "HTFanControl\HTFanControl.csproj", "{0CD5C424-C125-4389-9151-6805035B38CD}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FanTrayIcon", "FanTrayIcon\FanTrayIcon.csproj", "{3754A290-418A-41C5-9DA2-7CDFFE8B8B64}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | ReleaseLinux|Any CPU = ReleaseLinux|Any CPU 14 | ReleaseWin|Any CPU = ReleaseWin|Any CPU 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {0CD5C424-C125-4389-9151-6805035B38CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {0CD5C424-C125-4389-9151-6805035B38CD}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {0CD5C424-C125-4389-9151-6805035B38CD}.ReleaseLinux|Any CPU.ActiveCfg = ReleaseLinux|Any CPU 20 | {0CD5C424-C125-4389-9151-6805035B38CD}.ReleaseLinux|Any CPU.Build.0 = ReleaseLinux|Any CPU 21 | {0CD5C424-C125-4389-9151-6805035B38CD}.ReleaseWin|Any CPU.ActiveCfg = Debug|Any CPU 22 | {0CD5C424-C125-4389-9151-6805035B38CD}.ReleaseWin|Any CPU.Build.0 = Debug|Any CPU 23 | {3754A290-418A-41C5-9DA2-7CDFFE8B8B64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {3754A290-418A-41C5-9DA2-7CDFFE8B8B64}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {3754A290-418A-41C5-9DA2-7CDFFE8B8B64}.ReleaseLinux|Any CPU.ActiveCfg = Release|Any CPU 26 | {3754A290-418A-41C5-9DA2-7CDFFE8B8B64}.ReleaseLinux|Any CPU.Build.0 = Release|Any CPU 27 | {3754A290-418A-41C5-9DA2-7CDFFE8B8B64}.ReleaseWin|Any CPU.ActiveCfg = Release|Any CPU 28 | {3754A290-418A-41C5-9DA2-7CDFFE8B8B64}.ReleaseWin|Any CPU.Build.0 = Release|Any CPU 29 | EndGlobalSection 30 | GlobalSection(SolutionProperties) = preSolution 31 | HideSolutionNode = FALSE 32 | EndGlobalSection 33 | GlobalSection(ExtensibilityGlobals) = postSolution 34 | SolutionGuid = {9DD83935-CA4C-4052-A06F-FDF389380694} 35 | EndGlobalSection 36 | EndGlobal 37 | -------------------------------------------------------------------------------- /HTFanControl/Controllers/HTTPController.cs: -------------------------------------------------------------------------------- 1 | using HTFanControl.Util; 2 | using System; 3 | using System.Net.Http; 4 | using System.Threading.Tasks; 5 | 6 | namespace HTFanControl.Controllers 7 | { 8 | class HTTPController : IController 9 | { 10 | private Settings _settings; 11 | 12 | public string ErrorStatus { get; private set; } 13 | 14 | public HTTPController(Settings settings) 15 | { 16 | _settings = settings; 17 | } 18 | 19 | public bool SendCMD(string cmd) 20 | { 21 | switch (cmd) 22 | { 23 | case "OFF": 24 | SendHTTPPost(_settings.HTTP_OFF_URL); 25 | SendHTTPPost(_settings.HTTP_OFF_URL2); 26 | SendHTTPPost(_settings.HTTP_OFF_URL3); 27 | SendHTTPPost(_settings.HTTP_OFF_URL4); 28 | break; 29 | case "ECO": 30 | SendHTTPPost(_settings.HTTP_ECO_URL); 31 | SendHTTPPost(_settings.HTTP_ECO_URL2); 32 | SendHTTPPost(_settings.HTTP_ECO_URL3); 33 | SendHTTPPost(_settings.HTTP_ECO_URL4); 34 | break; 35 | case "LOW": 36 | SendHTTPPost(_settings.HTTP_LOW_URL); 37 | SendHTTPPost(_settings.HTTP_LOW_URL2); 38 | SendHTTPPost(_settings.HTTP_LOW_URL3); 39 | SendHTTPPost(_settings.HTTP_LOW_URL4); 40 | break; 41 | case "MED": 42 | SendHTTPPost(_settings.HTTP_MED_URL); 43 | SendHTTPPost(_settings.HTTP_MED_URL2); 44 | SendHTTPPost(_settings.HTTP_MED_URL3); 45 | SendHTTPPost(_settings.HTTP_MED_URL4); 46 | break; 47 | case "HIGH": 48 | SendHTTPPost(_settings.HTTP_HIGH_URL); 49 | SendHTTPPost(_settings.HTTP_HIGH_URL2); 50 | SendHTTPPost(_settings.HTTP_HIGH_URL3); 51 | SendHTTPPost(_settings.HTTP_HIGH_URL4); 52 | break; 53 | default: 54 | break; 55 | } 56 | 57 | return true; 58 | } 59 | 60 | private void SendHTTPPost(string url) 61 | { 62 | if (!string.IsNullOrEmpty(url)) 63 | { 64 | Task.Run(() => 65 | { 66 | using (HttpClient httpClient = new HttpClient()) 67 | { 68 | httpClient.Timeout = TimeSpan.FromMilliseconds(500); 69 | 70 | try 71 | { 72 | StringContent postData = null; 73 | 74 | _ = httpClient.PostAsync($"{url}", postData).Result; 75 | } 76 | catch { } 77 | } 78 | }); 79 | } 80 | } 81 | 82 | public bool Connect() { return true; } 83 | 84 | public void Disconnect() { } 85 | } 86 | } -------------------------------------------------------------------------------- /HTFanControl/Controllers/IController.cs: -------------------------------------------------------------------------------- 1 | namespace HTFanControl.Controllers 2 | { 3 | interface IController 4 | { 5 | string ErrorStatus 6 | { 7 | get; 8 | } 9 | 10 | bool Connect(); 11 | void Disconnect(); 12 | bool SendCMD(string cmd); 13 | } 14 | } -------------------------------------------------------------------------------- /HTFanControl/Controllers/LIRCController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Sockets; 4 | using System.Text; 5 | using System.Threading; 6 | using HTFanControl.Util; 7 | 8 | namespace HTFanControl.Controllers 9 | { 10 | class LIRCController : IController 11 | { 12 | private Socket _lircSocket; 13 | private Settings _settings; 14 | 15 | private bool _isOFF = true; 16 | private bool _needONcmd = false; 17 | 18 | public string ErrorStatus { get; private set; } 19 | 20 | public LIRCController(Settings settings) 21 | { 22 | _settings = settings; 23 | _needONcmd = _settings.LIRC_ON_Delay > 0; 24 | } 25 | 26 | public bool SendCMD(string cmd) 27 | { 28 | bool send = true; 29 | bool goodResult = true; 30 | 31 | //case when fan needs to be turned ON before a command can be sent 32 | if (_needONcmd && _isOFF) 33 | { 34 | if (cmd != "OFF") 35 | { 36 | string lircOnCMD = $"SEND_ONCE {_settings.LIRC_Remote} ON\n"; 37 | SendLIRCBytes(Encoding.ASCII.GetBytes(lircOnCMD)); 38 | 39 | Thread.Sleep(_settings.LIRC_ON_Delay); 40 | } 41 | //fan is already OFF, but it's being asked to turn OFF, so don't send a command, because that would cause it to turn ON again if ON/OFF are the same IR command 42 | else 43 | { 44 | send = false; 45 | } 46 | } 47 | 48 | if (send) 49 | { 50 | string lircCMD = $"SEND_ONCE {_settings.LIRC_Remote} {cmd}\n"; 51 | goodResult = SendLIRCBytes(Encoding.ASCII.GetBytes(lircCMD)); 52 | } 53 | 54 | if (cmd == "OFF") 55 | { 56 | _isOFF = true; 57 | } 58 | else 59 | { 60 | _isOFF = false; 61 | } 62 | 63 | if (!goodResult) 64 | { 65 | return false; 66 | } 67 | else 68 | { 69 | return true; 70 | } 71 | } 72 | 73 | private bool SendLIRCBytes(byte[] cmd) 74 | { 75 | bool tryAgain = false; 76 | try 77 | { 78 | _lircSocket.Send(cmd); 79 | Log.LogTrace(Encoding.ASCII.GetString(cmd)); 80 | 81 | if ((_lircSocket.Poll(1000, SelectMode.SelectRead) && (_lircSocket.Available == 0)) || !_lircSocket.Connected) 82 | { 83 | throw new Exception(); 84 | } 85 | } 86 | catch 87 | { 88 | tryAgain = true; 89 | } 90 | 91 | if (tryAgain) 92 | { 93 | try 94 | { 95 | Thread.Sleep(75); 96 | Connect(); 97 | 98 | _lircSocket.Send(cmd); 99 | Log.LogTrace(Encoding.ASCII.GetString(cmd)); 100 | 101 | if ((_lircSocket.Poll(1000, SelectMode.SelectRead) && (_lircSocket.Available == 0)) || !_lircSocket.Connected) 102 | { 103 | throw new Exception(); 104 | } 105 | } 106 | catch 107 | { 108 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Failed sending command to LIRC at: {_settings.LIRC_IP}:{_settings.LIRC_Port}"; 109 | return false; 110 | } 111 | } 112 | 113 | return true; 114 | } 115 | 116 | public bool Connect() 117 | { 118 | Disconnect(); 119 | 120 | try 121 | { 122 | IPAddress ipAddress = IPAddress.Parse(_settings.LIRC_IP); 123 | IPEndPoint remoteEP = new IPEndPoint(ipAddress, _settings.LIRC_Port); 124 | _lircSocket = new Socket(ipAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp); 125 | 126 | IAsyncResult result = _lircSocket.BeginConnect(remoteEP, null, null); 127 | result.AsyncWaitHandle.WaitOne(3000); 128 | 129 | if (!_lircSocket.Connected) 130 | { 131 | throw new Exception(); 132 | } 133 | 134 | _lircSocket.EndConnect(result); 135 | 136 | Thread.Sleep(25); 137 | 138 | if (ConfigHelper.OS != "win") 139 | { 140 | SendLIRCBytes(Encoding.ASCII.GetBytes($"SET_TRANSMITTERS 1 2 3 4\n")); 141 | } 142 | } 143 | catch 144 | { 145 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Cannot connect to LIRC at: {_settings.LIRC_IP}:{_settings.LIRC_Port}"; 146 | return false; 147 | } 148 | 149 | Thread.Sleep(25); 150 | 151 | return true; 152 | } 153 | 154 | public void Disconnect() 155 | { 156 | if (_lircSocket != null) 157 | { 158 | try 159 | { 160 | _lircSocket.Shutdown(SocketShutdown.Both); 161 | _lircSocket.Close(); 162 | } 163 | catch { } 164 | } 165 | } 166 | } 167 | } -------------------------------------------------------------------------------- /HTFanControl/Controllers/MQTTController.cs: -------------------------------------------------------------------------------- 1 | using MQTTnet; 2 | using MQTTnet.Client; 3 | using MQTTnet.Client.Options; 4 | using System; 5 | using System.Threading; 6 | using HTFanControl.Util; 7 | 8 | namespace HTFanControl.Controllers 9 | { 10 | class MQTTController : IController 11 | { 12 | private IMqttClient _mqttClient; 13 | private Settings _settings; 14 | private bool _isOFF = true; 15 | private bool _ONcmd = false; 16 | 17 | public string ErrorStatus { get; private set; } 18 | 19 | public MQTTController(Settings settings) 20 | { 21 | _settings = settings; 22 | _ONcmd = _settings.MQTT_ON_Delay > 0; 23 | } 24 | 25 | public bool SendCMD(string cmd) 26 | { 27 | bool send = true; 28 | 29 | if (!_mqttClient.IsConnected) 30 | { 31 | Connect(); 32 | } 33 | 34 | string MQTT_Topic; 35 | string MQTT_Payload; 36 | 37 | if (!_settings.MQTT_Advanced_Mode) 38 | { 39 | MQTT_Topic = cmd switch 40 | { 41 | "OFF" => _settings.MQTT_OFF_Topic, 42 | "ECO" => _settings.MQTT_ECO_Topic, 43 | "LOW" => _settings.MQTT_LOW_Topic, 44 | "MED" => _settings.MQTT_MED_Topic, 45 | "HIGH" => _settings.MQTT_HIGH_Topic, 46 | _ => null, 47 | }; 48 | MQTT_Payload = cmd switch 49 | { 50 | "OFF" => _settings.MQTT_OFF_Payload, 51 | "ECO" => _settings.MQTT_ECO_Payload, 52 | "LOW" => _settings.MQTT_LOW_Payload, 53 | "MED" => _settings.MQTT_MED_Payload, 54 | "HIGH" => _settings.MQTT_HIGH_Payload, 55 | _ => null, 56 | }; 57 | } 58 | else 59 | { 60 | _settings.MQTT_Topics.TryGetValue(cmd, out MQTT_Topic); 61 | _settings.MQTT_Payloads.TryGetValue(cmd, out MQTT_Payload); 62 | } 63 | 64 | //case when using IR over MQTT and fan needs to be turned ON before a command can be sent 65 | if (_ONcmd && _isOFF) 66 | { 67 | if(cmd != "OFF") 68 | { 69 | string ON_Topic = null; 70 | string ON_Payload = null; 71 | 72 | try 73 | { 74 | if (!_settings.MQTT_Advanced_Mode) 75 | { 76 | ON_Topic = _settings.MQTT_ON_Topic; 77 | ON_Payload = _settings.MQTT_ON_Payload; 78 | } 79 | else 80 | { 81 | _settings.MQTT_Topics.TryGetValue("ON", out ON_Topic); 82 | _settings.MQTT_Payloads.TryGetValue("ON", out ON_Payload); 83 | } 84 | 85 | MqttApplicationMessage message = new MqttApplicationMessageBuilder() 86 | .WithTopic(ON_Topic) 87 | .WithPayload(ON_Payload) 88 | .Build(); 89 | 90 | _mqttClient.PublishAsync(message); 91 | } 92 | catch 93 | { 94 | ErrorStatus = @$"({DateTime.Now:h:mm:ss tt}) Failed turning fan ON by sending Topic: ""{ON_Topic}"" and Payload: ""{ON_Payload}"" To: {_settings.MQTT_IP}:{_settings.MQTT_Port}"; 95 | return false; 96 | } 97 | 98 | Thread.Sleep(_settings.MQTT_ON_Delay); 99 | } 100 | //fan is already OFF, but it's being asked to turn OFF, so don't send a payload, because that would cause it to turn ON again if ON/OFF are the same IR command 101 | else 102 | { 103 | send = false; 104 | } 105 | } 106 | 107 | if (send) 108 | { 109 | try 110 | { 111 | MqttApplicationMessage message = new MqttApplicationMessageBuilder() 112 | .WithTopic(MQTT_Topic) 113 | .WithPayload(MQTT_Payload) 114 | .Build(); 115 | 116 | IAsyncResult result = _mqttClient.PublishAsync(message); 117 | result.AsyncWaitHandle.WaitOne(1000); 118 | } 119 | catch 120 | { 121 | ErrorStatus = @$"({DateTime.Now:h:mm:ss tt}) Failed sending Topic: ""{MQTT_Topic}"" and Payload: ""{MQTT_Payload}"" To: {_settings.MQTT_IP}:{_settings.MQTT_Port}"; 122 | return false; 123 | } 124 | } 125 | 126 | if (cmd == "OFF") 127 | { 128 | _isOFF = true; 129 | } 130 | else 131 | { 132 | _isOFF = false; 133 | } 134 | 135 | return true; 136 | } 137 | 138 | public bool Connect() 139 | { 140 | Disconnect(); 141 | 142 | try 143 | { 144 | MqttFactory factory = new MqttFactory(); 145 | _mqttClient = factory.CreateMqttClient(); 146 | 147 | int? port = null; 148 | if (_settings.MQTT_Port != 0) 149 | { 150 | port = _settings.MQTT_Port; 151 | } 152 | 153 | IMqttClientOptions options = new MqttClientOptionsBuilder() 154 | .WithTcpServer(_settings.MQTT_IP, port) 155 | .WithCredentials(_settings.MQTT_User, _settings.MQTT_Pass) 156 | .Build(); 157 | 158 | IAsyncResult result = _mqttClient.ConnectAsync(options); 159 | result.AsyncWaitHandle.WaitOne(3000); 160 | 161 | if (!_mqttClient.IsConnected) 162 | { 163 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Failed to connect to MQTT broker at: {_settings.MQTT_IP}:{_settings.MQTT_Port}"; 164 | return false; 165 | } 166 | } 167 | catch 168 | { 169 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Cannot connect to MQTT broker at: {_settings.MQTT_IP}:{_settings.MQTT_Port}"; 170 | return false; 171 | } 172 | 173 | return true; 174 | } 175 | 176 | public void Disconnect() 177 | { 178 | if (_mqttClient != null) 179 | { 180 | try 181 | { 182 | _mqttClient.DisconnectAsync(); 183 | _mqttClient.Dispose(); 184 | } 185 | catch { } 186 | } 187 | } 188 | } 189 | } -------------------------------------------------------------------------------- /HTFanControl/HTFanControl.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net5.0-windows7.0 6 | htfancontrol.ico 7 | Debug;ReleaseWin;ReleaseLinux 8 | true 9 | 0.4.2.2 10 | 0.4.2.2 11 | 0.4.2.2 12 | 4D Theater Wind Effect - DIY Home Theater Project 13 | nicko88 14 | nicko88 15 | 16 | 17 | 18 | true 19 | TRACE 20 | 21 | 22 | 23 | TRACE 24 | true 25 | 26 | 27 | 28 | false 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /HTFanControl/Main/HTFanControl.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading; 6 | using HTFanControl.Timers; 7 | using System.IO.Compression; 8 | using HTFanControl.Controllers; 9 | using HTFanControl.Players; 10 | using HTFanControl.Util; 11 | 12 | namespace HTFanControl.Main 13 | { 14 | class HTFanControl 15 | { 16 | public string _errorStatus; 17 | public string _windtrackError; 18 | public long _loadedVideoTime; 19 | public string _loadedVideoFilename; 20 | public string _currentWindtrackPath; 21 | public string _windTrackHeader; 22 | public int _curCmdIndex = -1; 23 | public int _nextCmdIndex; 24 | public bool _isPlaying = false; 25 | public bool _isEnabled = true; 26 | public bool _hasOffset = false; 27 | public double _offset; 28 | public bool _offsetEnabled = false; 29 | 30 | private PositionTimer _videoTimer; 31 | private readonly Timer _syncTimer; 32 | private IPlayer _mediaPlayer; 33 | public AudioSync _audioSync; 34 | public IController _fanController; 35 | 36 | public List> _videoTimeCodes; 37 | 38 | public Settings _settings; 39 | public Log _log; 40 | 41 | public HTFanControl() 42 | { 43 | _log = new Log(); 44 | 45 | _settings = Settings.LoadSettings(); 46 | Settings.SaveSettings(_settings); 47 | 48 | _syncTimer = new Timer(SyncTimerTick, null, Timeout.Infinite, Timeout.Infinite); 49 | 50 | SelectSyncSource(); 51 | SelectController(); 52 | } 53 | 54 | public void ReInitialize(bool fullRefresh) 55 | { 56 | _videoTimer?.Stop(); 57 | _loadedVideoFilename = ""; 58 | _windTrackHeader = ""; 59 | _loadedVideoTime = 0; 60 | _videoTimeCodes = null; 61 | _curCmdIndex = -1; 62 | _nextCmdIndex = 0; 63 | _isPlaying = false; 64 | 65 | if (_settings.MediaPlayerType == "Audio") 66 | { 67 | _audioSync?.Stop(); 68 | } 69 | 70 | SelectSyncSource(); 71 | 72 | if (fullRefresh) 73 | { 74 | SelectController(); 75 | } 76 | } 77 | 78 | private void SelectController() 79 | { 80 | _fanController?.Disconnect(); 81 | 82 | _fanController = _settings.ControllerType switch 83 | { 84 | "LIRC" => new LIRCController(_settings), 85 | "MQTT" => new MQTTController(_settings), 86 | "HTTP" => new HTTPController(_settings), 87 | _ => new LIRCController(_settings), 88 | }; 89 | 90 | if(!_fanController.Connect()) 91 | { 92 | _errorStatus = _fanController.ErrorStatus; 93 | } 94 | } 95 | 96 | private void SelectSyncSource() 97 | { 98 | if (_settings.MediaPlayerType == "Audio") 99 | { 100 | _audioSync = new AudioSync(this); 101 | _syncTimer.Change(Timeout.Infinite, Timeout.Infinite); 102 | } 103 | else 104 | { 105 | _mediaPlayer = _settings.MediaPlayerType switch 106 | { 107 | "MPC" => new MPCPlayer(_settings), 108 | "Kodi" => new KodiPlayer(_settings), 109 | "KodiMPC" => new KodiPlayer(_settings), 110 | "Plex" => new PlexPlayer(_settings), 111 | "RokuPlex" => new RokuPlexPlayer(_settings), 112 | "Zidoo" => new ZidooPlayer(_settings), 113 | _ => new MPCPlayer(_settings), 114 | }; 115 | 116 | _syncTimer.Change(1000, Timeout.Infinite); 117 | } 118 | } 119 | 120 | public async void SelectVideo(string fileName) 121 | { 122 | ExtractWindtrack(Path.Combine(ConfigHelper._rootPath, "windtracks", fileName + ".zip"), true); 123 | 124 | _loadedVideoFilename = "Loading Video Fingerprints..."; 125 | _windTrackHeader = "Loading Windtrack..."; 126 | 127 | _audioSync.Start(fileName); 128 | _loadedVideoFilename = fileName; 129 | LoadVideoTimecodes(fileName, ""); 130 | 131 | if (_videoTimer != null) 132 | { 133 | await _videoTimer.DisposeAsync(_videoTimeCodes == null); 134 | } 135 | if (_videoTimeCodes != null) 136 | { 137 | _videoTimer = new PositionTimer<(string, int)>(_videoTimeCodes.Select((v, i) => (v.Item1, (v.Item2, i))), SendCmd, 1000, ("OFF", -1)); 138 | } 139 | else 140 | { 141 | _videoTimer = null; 142 | } 143 | } 144 | 145 | public void UpdateTime() 146 | { 147 | _videoTimer.Update(TimeSpan.FromMilliseconds(_loadedVideoTime)); 148 | } 149 | 150 | public void ToggleFan() 151 | { 152 | if (_isEnabled) 153 | { 154 | _isEnabled = false; 155 | 156 | if (!_fanController.SendCMD("OFF")) 157 | { 158 | _errorStatus = _fanController.ErrorStatus; 159 | } 160 | _log.LogMsg("Fans Disabled"); 161 | } 162 | else 163 | { 164 | _log.LogMsg("Fans Enabled"); 165 | 166 | _isEnabled = true; 167 | 168 | if (_videoTimer != null) 169 | { 170 | try 171 | { 172 | if (_isPlaying) 173 | { 174 | if (!_fanController.SendCMD(_videoTimeCodes[_curCmdIndex].Item2)) 175 | { 176 | _errorStatus = _fanController.ErrorStatus; 177 | } 178 | _log.LogMsg($"Sent CMD: {_videoTimeCodes[_curCmdIndex].Item1.ToString("G").Substring(2, 12)},{_videoTimeCodes[_curCmdIndex].Item2}"); 179 | } 180 | } 181 | catch { } 182 | } 183 | } 184 | } 185 | 186 | private void SyncTimerTick(object o) 187 | { 188 | SyncVideo(); 189 | } 190 | 191 | private async void SyncVideo() 192 | { 193 | if (_mediaPlayer != null) 194 | { 195 | bool success = _mediaPlayer.Update(); 196 | 197 | if (success) 198 | { 199 | _isPlaying = _mediaPlayer.IsPlaying; 200 | _loadedVideoTime = _mediaPlayer.VideoTime; 201 | 202 | if (_loadedVideoFilename != _mediaPlayer.FileName) 203 | { 204 | _loadedVideoFilename = _mediaPlayer.FileName; 205 | LoadVideoTimecodes(_mediaPlayer.FileName, _mediaPlayer.FilePath); 206 | 207 | if (_videoTimer != null) 208 | { 209 | await _videoTimer.DisposeAsync(_videoTimeCodes == null); 210 | } 211 | if (_videoTimeCodes != null) 212 | { 213 | _videoTimer = new PositionTimer<(string, int)>(_videoTimeCodes.Select((v, i) => (v.Item1, (v.Item2, i))), SendCmd, _mediaPlayer.VideoTimeResolution, ("OFF", -1)); 214 | } 215 | else 216 | { 217 | _videoTimer = null; 218 | } 219 | } 220 | 221 | if (_videoTimer != null) 222 | { 223 | if (_mediaPlayer.IsPlaying) 224 | { 225 | _videoTimer.Update(TimeSpan.FromMilliseconds(_mediaPlayer.VideoTime)); 226 | } 227 | else 228 | { 229 | _videoTimer.Stop(); 230 | } 231 | } 232 | } 233 | else 234 | { 235 | _errorStatus = _mediaPlayer.ErrorStatus; 236 | ReInitialize(false); 237 | } 238 | } 239 | 240 | if (_settings.MediaPlayerType != "Audio") 241 | { 242 | _syncTimer.Change(1000, Timeout.Infinite); 243 | } 244 | } 245 | 246 | private void SendCmd(PositionTimer videoTimer, (string cmd, int index) command) 247 | { 248 | (string fanCmd, int i) = command; 249 | _curCmdIndex = i; 250 | 251 | if(i == -1) 252 | { 253 | fanCmd = "OFF"; 254 | } 255 | else 256 | { 257 | _nextCmdIndex = i + 1; 258 | } 259 | 260 | try 261 | { 262 | _log.LogMsg($"Sent CMD: {_videoTimeCodes[i].Item1.ToString("G").Substring(2, 12)},{fanCmd}"); 263 | } 264 | catch 265 | { 266 | _log.LogMsg($"Sent CMD: {fanCmd}"); 267 | } 268 | 269 | if (_isEnabled) 270 | { 271 | if (!_fanController.SendCMD(fanCmd)) 272 | { 273 | _errorStatus = _fanController.ErrorStatus; 274 | } 275 | } 276 | 277 | //global minimum command gap 278 | Thread.Sleep(250); 279 | } 280 | 281 | private string GetWindtrackFilePath(string fileName, string filePath) 282 | { 283 | string validFilePath = null; 284 | 285 | //LEGACY look in windtrack folder for .txt 286 | try 287 | { 288 | if (string.IsNullOrEmpty(validFilePath) && File.Exists(Path.Combine(ConfigHelper._rootPath, "windtracks", fileName + ".txt"))) 289 | { 290 | validFilePath = Path.Combine(ConfigHelper._rootPath, "windtracks", fileName + ".txt"); 291 | } 292 | } 293 | catch { } 294 | 295 | //LEGACY check the active video's folder for .txt 296 | try 297 | { 298 | if (string.IsNullOrEmpty(validFilePath) && File.Exists(Path.Combine(filePath, fileName + ".txt"))) 299 | { 300 | validFilePath = Path.Combine(filePath, fileName + ".txt"); 301 | } 302 | } 303 | catch { } 304 | 305 | //look for windtrack .zip archive in windtracks folder 306 | try 307 | { 308 | if (string.IsNullOrEmpty(validFilePath) && File.Exists(Path.Combine(ConfigHelper._rootPath, "windtracks", fileName + ".zip"))) 309 | { 310 | ExtractWindtrack(Path.Combine(ConfigHelper._rootPath, "windtracks", fileName + ".zip"), false); 311 | validFilePath = Path.Combine(ConfigHelper._rootPath, "tmp", fileName + ".txt"); 312 | } 313 | } 314 | catch { } 315 | 316 | //if not found, look in the active video's folder 317 | try 318 | { 319 | if (string.IsNullOrEmpty(validFilePath) && File.Exists(Path.Combine(filePath, fileName + ".zip"))) 320 | { 321 | ExtractWindtrack(Path.Combine(filePath, fileName + ".zip"), false); 322 | validFilePath = Path.Combine(ConfigHelper._rootPath, "tmp", fileName + ".txt"); 323 | } 324 | } 325 | catch { } 326 | 327 | return validFilePath; 328 | } 329 | 330 | private void LoadVideoTimecodes(string fileName, string filePath) 331 | { 332 | string validFilePath = GetWindtrackFilePath(fileName, filePath); 333 | 334 | if (!string.IsNullOrEmpty(validFilePath)) 335 | { 336 | if (validFilePath != _currentWindtrackPath) 337 | { 338 | _currentWindtrackPath = validFilePath; 339 | _offsetEnabled = false; 340 | } 341 | 342 | _windtrackError = null; 343 | 344 | _videoTimeCodes = new List>(); 345 | _windTrackHeader = ""; 346 | 347 | string[] lines = File.ReadAllLines(validFilePath); 348 | _offset = 0; 349 | _hasOffset = false; 350 | string lastCmd = "OFF"; 351 | double rawPrevTime = -500; 352 | double actualPrevTime = -500; 353 | 354 | for (int i = 0; i < lines.Length; i++) 355 | { 356 | string line = lines[i]; 357 | 358 | //header lines 359 | if (line.StartsWith("#") && _videoTimeCodes.Count == 0) 360 | { 361 | _windTrackHeader += line.TrimStart(new[] { '#', ' ' }) + "
"; 362 | 363 | //offset line 364 | if (line.ToLower().Contains("offset:")) 365 | { 366 | try 367 | { 368 | TimeSpan ts = TimeSpan.Parse(line.Substring(line.IndexOf('(') + 1, line.LastIndexOf(')') - line.IndexOf('(') - 1)); 369 | _offset = ts.TotalMilliseconds; 370 | _hasOffset = true; 371 | } 372 | catch { } 373 | } 374 | 375 | if (line.ToLower().Contains("enableoffset")) 376 | { 377 | _offsetEnabled = true; 378 | } 379 | } 380 | //non-comment or blank lines 381 | else if (!line.StartsWith(@"\\") && !line.StartsWith(@"//") && line.Trim().Length > 0) 382 | { 383 | //parse line 384 | string[] lineData = line.Split(','); 385 | string lineTime = lineData[0]; 386 | string lineCmd = lineData[1]; 387 | 388 | //check if this timecode contains a fan command so we can apply offsets 389 | bool isFanCmd = false; 390 | 391 | if (lineCmd == "OFF" || lineCmd == "ECO" || lineCmd == "LOW" || lineCmd == "MED" || lineCmd == "HIGH") 392 | { 393 | isFanCmd = true; 394 | } 395 | 396 | double? timeCode = null; 397 | try 398 | { 399 | timeCode = TimeSpan.Parse(lineTime).TotalMilliseconds; 400 | } 401 | catch 402 | { 403 | if (_windtrackError is null) 404 | { 405 | _windtrackError = $"Bad timecode on line {i+1}: {line}"; 406 | } 407 | } 408 | 409 | if (timeCode != null) 410 | { 411 | if (isFanCmd) 412 | { 413 | timeCode = timeCode - _settings.GlobalOffsetMS; 414 | } 415 | 416 | rawPrevTime = (double)timeCode; 417 | 418 | if (isFanCmd) 419 | { 420 | //if command comes after OFF, add spinup offset 421 | if (lastCmd == "OFF") 422 | { 423 | if(lineCmd == "ECO") 424 | { 425 | timeCode -= _settings.ECOSpinupOffsetMS; 426 | } 427 | else if (lineCmd == "LOW") 428 | { 429 | timeCode -= _settings.LOWSpinupOffsetMS; 430 | } 431 | else if (lineCmd == "MED") 432 | { 433 | timeCode -= _settings.MEDSpinupOffsetMS; 434 | } 435 | else if (lineCmd == "HIGH") 436 | { 437 | timeCode -= _settings.HIGHSpinupOffsetMS; 438 | } 439 | } 440 | //if command is OFF, add spindown offset 441 | else if (lineCmd == "OFF") 442 | { 443 | timeCode -= _settings.SpindownOffsetMS; 444 | } 445 | //if offset makes timecode invalid, fix it 446 | if (timeCode < actualPrevTime + 500) 447 | { 448 | timeCode = actualPrevTime + 500; 449 | } 450 | } 451 | 452 | //ignore offset if it's not enabled 453 | if(!_offsetEnabled) 454 | { 455 | _offset = 0; 456 | } 457 | 458 | //keep clearing the list if the timecode is less than or equal to 0 so that we only end up with 1 timecode at 0 at the start 459 | if ((timeCode + _offset) <= 0) 460 | { 461 | _videoTimeCodes.Clear(); 462 | timeCode = 0; 463 | } 464 | 465 | _videoTimeCodes.Add(new Tuple(TimeSpan.FromMilliseconds((double)timeCode + _offset), lineData[1])); 466 | 467 | if (isFanCmd) 468 | { 469 | lastCmd = lineCmd; 470 | actualPrevTime = (double)timeCode; 471 | } 472 | } 473 | } 474 | } 475 | 476 | //sort timecodes just to be safe for special cases where they could be out of order 477 | _videoTimeCodes = _videoTimeCodes.OrderBy(v => v.Item1).ToList(); 478 | } 479 | else 480 | { 481 | _videoTimeCodes = null; 482 | } 483 | } 484 | 485 | private void ExtractWindtrack(string filePath, bool extractFingerprint) 486 | { 487 | try 488 | { 489 | DirectoryInfo directoryInfo = new DirectoryInfo(Path.Combine(ConfigHelper._rootPath, "tmp")); 490 | 491 | foreach (FileInfo file in directoryInfo.GetFiles()) 492 | { 493 | file.Delete(); 494 | } 495 | foreach (DirectoryInfo dir in directoryInfo.GetDirectories()) 496 | { 497 | dir.Delete(true); 498 | } 499 | } 500 | catch { } 501 | 502 | string fileName = Path.GetFileNameWithoutExtension(filePath); 503 | 504 | try 505 | { 506 | using ZipArchive archive = ZipFile.OpenRead(filePath); 507 | 508 | archive.Entries.Where(e => e.Name.Equals("commands.txt")).Single().ExtractToFile(Path.Combine(ConfigHelper._rootPath, "tmp", fileName + ".txt"), true); 509 | 510 | if (extractFingerprint) 511 | { 512 | Directory.CreateDirectory(Path.Combine(ConfigHelper._rootPath, "tmp", "fingerprint")); 513 | archive.Entries.Where(e => e.Name.Equals("full.fingerprints")).Single().ExtractToFile(Path.Combine(new string[] { ConfigHelper._rootPath, "tmp", "fingerprint", "audio" }), true); 514 | } 515 | } 516 | catch 517 | { 518 | _errorStatus = $"Failed to extract windtrack file: {filePath}"; 519 | } 520 | } 521 | } 522 | } -------------------------------------------------------------------------------- /HTFanControl/Players/AudioSync.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using SoundFingerprinting.Audio; 7 | using SoundFingerprinting.Builder; 8 | using SoundFingerprinting.Command; 9 | using SoundFingerprinting.InMemory; 10 | using SoundFingerprinting.Query; 11 | using OpenTK.Audio.OpenAL; 12 | using System.IO; 13 | using System.Net.Http; 14 | using System.Linq; 15 | using HTFanControl.Util; 16 | 17 | namespace HTFanControl.Main 18 | { 19 | class AudioSync 20 | { 21 | private bool verifyAccuracy = false; 22 | private string _state; 23 | private TimeSpan _lastMatchTime; 24 | private bool _timeJump = false; 25 | 26 | private InMemoryModelService _modelService; 27 | 28 | private CancellationTokenSource tokenSource; 29 | private BlockingCollection _realtimeSource; 30 | private List _float32Buffer = new List(); 31 | 32 | //private Timer _pause; 33 | 34 | private Thread _recordMic; 35 | private HTFanControl _hTFanControl; 36 | 37 | public string State 38 | { 39 | get 40 | { 41 | return _state; 42 | } 43 | } 44 | 45 | public AudioSync(HTFanControl hTFanControl) 46 | { 47 | _hTFanControl = hTFanControl; 48 | 49 | if(File.Exists(Path.Combine(ConfigHelper._rootPath, "testaccuracy.txt"))) 50 | { 51 | verifyAccuracy = true; 52 | } 53 | } 54 | 55 | public void Start(string fileName) 56 | { 57 | tokenSource = new CancellationTokenSource(); 58 | 59 | LoadFingerprint(fileName); 60 | 61 | _recordMic = new Thread(RecordOpenTK); 62 | _recordMic.Start(tokenSource.Token); 63 | 64 | //_pause = new Timer(Pause, null, Timeout.Infinite, Timeout.Infinite); 65 | 66 | _lastMatchTime = TimeSpan.MinValue; 67 | 68 | StartMatching(tokenSource.Token); 69 | } 70 | 71 | public void Stop() 72 | { 73 | if (_modelService != null) 74 | { 75 | _hTFanControl._log.LogMsg("Stop Listening..."); 76 | } 77 | 78 | _state = ""; 79 | 80 | _modelService = null; 81 | _realtimeSource = null; 82 | _float32Buffer = new List(); 83 | 84 | try 85 | { 86 | //_pause.Change(Timeout.Infinite, Timeout.Infinite); 87 | tokenSource.Cancel(); 88 | } 89 | catch { } 90 | } 91 | 92 | private void LoadFingerprint(string fileName) 93 | { 94 | string validFilePath = null; 95 | try 96 | { 97 | if (File.Exists(Path.Combine(new string[] { ConfigHelper._rootPath, "tmp", "fingerprint", "audio" }))) 98 | { 99 | validFilePath = Path.Combine(new string[] { ConfigHelper._rootPath, "tmp", "fingerprint" }); 100 | } 101 | 102 | _modelService = new InMemoryModelService(validFilePath); 103 | } 104 | catch 105 | { 106 | _hTFanControl._errorStatus = $"Failed to load audio fingerprints from: {validFilePath}"; 107 | } 108 | } 109 | 110 | private async void StartMatching(CancellationToken cancellationToken) 111 | { 112 | _realtimeSource = new BlockingCollection(); 113 | 114 | _hTFanControl._log.LogMsg("Start Listening..."); 115 | _state = "(listening...)"; 116 | 117 | try 118 | { 119 | _ = await GetBestMatchForStream(_realtimeSource, cancellationToken); 120 | } 121 | catch { } 122 | } 123 | 124 | private void FoundMatch(AVQueryResult aVQueryResult) 125 | { 126 | if (aVQueryResult.ContainsMatches) 127 | { 128 | ResultEntry resultEntry = aVQueryResult.ResultEntries.First().Audio; 129 | 130 | _timeJump = false; 131 | TimeSpan matchTime = TimeSpan.FromSeconds(resultEntry.TrackMatchStartsAt + resultEntry.QueryLength + 0.2 /*+ TimeSpan.FromMilliseconds(aVQueryResult.QueryCommandStats.Audio.TotalDurationMilliseconds).TotalSeconds*/); 132 | 133 | if (matchTime > _lastMatchTime.Add(TimeSpan.FromMinutes(5)) || matchTime < _lastMatchTime.Subtract(TimeSpan.FromMinutes(5))) 134 | { 135 | _timeJump = true; 136 | _lastMatchTime = matchTime; 137 | _hTFanControl._log.LogMsg("Time Jump Detected"); 138 | } 139 | 140 | if (!_timeJump) 141 | { 142 | _hTFanControl._log.LogMsg($"Match Found: {matchTime.ToString("G").Substring(2, 12)}"); 143 | _hTFanControl._loadedVideoTime = Convert.ToInt64(matchTime.TotalMilliseconds); 144 | _hTFanControl.UpdateTime(); 145 | _lastMatchTime = matchTime; 146 | 147 | if (verifyAccuracy) 148 | { 149 | VerifyAccuracy(matchTime); 150 | } 151 | } 152 | 153 | //_pause.Change(10000, Timeout.Infinite); 154 | } 155 | } 156 | 157 | private void VerifyAccuracy(TimeSpan audioTime) 158 | { 159 | long position = 0; 160 | try 161 | { 162 | HttpClient httpClient = new HttpClient(); 163 | string html = httpClient.GetStringAsync($"http://{_hTFanControl._settings.MediaPlayerIP}:{_hTFanControl._settings.MediaPlayerPort}/variables.html").Result; 164 | 165 | HtmlAgilityPack.HtmlDocument doc = new HtmlAgilityPack.HtmlDocument(); 166 | doc.LoadHtml(html); 167 | 168 | position = long.Parse(doc.GetElementbyId("position").InnerText) + 21; 169 | } 170 | catch { } 171 | 172 | TimeSpan playerTime = TimeSpan.FromMilliseconds(position); 173 | string matchResult = $"Accuracy:{audioTime.Subtract(playerTime).TotalMilliseconds} AudioTime:{audioTime.ToString("G").Substring(2, 12)} PlayerTime:{playerTime.ToString("G").Substring(2, 12)}"; 174 | _hTFanControl._log.LogMsg(matchResult); 175 | } 176 | 177 | //private void Pause(object o) 178 | //{ 179 | // _pause.Change(Timeout.Infinite, Timeout.Infinite); 180 | // _hTFanControl._log.LogMsg("PAUSED"); 181 | //} 182 | 183 | private void RecordOpenTK(object cancellationToken) 184 | { 185 | CancellationToken token = (CancellationToken)cancellationToken; 186 | 187 | ALCaptureDevice captureDevice = ALC.CaptureOpenDevice(_hTFanControl._settings.AudioDevice, 11024, ALFormat.Mono16, 10240); 188 | { 189 | ALC.CaptureStart(captureDevice); 190 | 191 | while (true) 192 | { 193 | try 194 | { 195 | token.ThrowIfCancellationRequested(); 196 | 197 | //wait for some audio samples to accumulate 198 | Thread.Sleep(100); 199 | 200 | if (captureDevice.Handle != IntPtr.Zero) 201 | { 202 | int samplesAvailable = ALC.GetAvailableSamples(captureDevice); 203 | 204 | if (samplesAvailable > 0) 205 | { 206 | short[] samples = new short[samplesAvailable]; 207 | ALC.CaptureSamples(captureDevice, ref samples[0], samplesAvailable); 208 | 209 | for (int i = 0; i < samples.Length; i += 2) 210 | { 211 | _float32Buffer.Add(samples[i] / 32767f); 212 | } 213 | 214 | _realtimeSource.Add(new AudioSamples(_float32Buffer.ToArray(), string.Empty, 5512)); 215 | _float32Buffer = new List(); 216 | } 217 | } 218 | else 219 | { 220 | _hTFanControl._errorStatus = $"Failed to record from audio input device: {_hTFanControl._settings.AudioDevice}"; 221 | } 222 | } 223 | catch 224 | { 225 | ALC.CaptureStop(captureDevice); 226 | ALC.CaptureCloseDevice(captureDevice); 227 | break; 228 | } 229 | } 230 | } 231 | } 232 | 233 | public async Task GetBestMatchForStream(BlockingCollection audioSamples, CancellationToken token) 234 | { 235 | double seconds = await QueryCommandBuilder.Instance 236 | .BuildRealtimeQueryCommand() 237 | .From(new BlockingRealtimeCollection(audioSamples)) 238 | .WithRealtimeQueryConfig(config => 239 | { 240 | config.ResultEntryFilter = new TrackMatchLengthEntryFilter(3d); 241 | config.SuccessCallback = result => FoundMatch(result); 242 | config.AutomaticSkipDetection = true; 243 | return config; 244 | }) 245 | .UsingServices(_modelService) 246 | .Query(token); 247 | return seconds; 248 | } 249 | } 250 | } -------------------------------------------------------------------------------- /HTFanControl/Players/IPlayer.cs: -------------------------------------------------------------------------------- 1 | namespace HTFanControl.Players 2 | { 3 | interface IPlayer 4 | { 5 | bool IsPlaying 6 | { 7 | get; 8 | } 9 | 10 | long VideoTime 11 | { 12 | get; 13 | } 14 | 15 | string FileName 16 | { 17 | get; 18 | } 19 | 20 | string FilePath 21 | { 22 | get; 23 | } 24 | 25 | string ErrorStatus 26 | { 27 | get; 28 | } 29 | 30 | int VideoTimeResolution 31 | { 32 | get; 33 | } 34 | 35 | bool Update(); 36 | } 37 | } -------------------------------------------------------------------------------- /HTFanControl/Players/KodiPlayer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Text.Json; 6 | using System.Web; 7 | using HTFanControl.Util; 8 | 9 | namespace HTFanControl.Players 10 | { 11 | class KodiPlayer : IPlayer 12 | { 13 | private HttpClient _httpClient; 14 | private Settings _settings; 15 | private string _playerID = null; 16 | 17 | public bool IsPlaying { get; private set; } 18 | public long VideoTime { get; private set; } 19 | public string FileName { get; private set; } 20 | public string FilePath { get; private set; } 21 | public string ErrorStatus { get; private set; } 22 | public int VideoTimeResolution { get; private set; } 23 | 24 | public KodiPlayer(Settings settings) 25 | { 26 | VideoTimeResolution = 50; 27 | 28 | _settings = settings; 29 | 30 | _httpClient = new HttpClient(); 31 | _httpClient.Timeout = TimeSpan.FromSeconds(1); 32 | } 33 | 34 | public bool Update() 35 | { 36 | try 37 | { 38 | if(_playerID is null) 39 | { 40 | StringContent playerIDJSONRequest = new StringContent(@"{""jsonrpc"": ""2.0"", ""method"": ""Player.GetActivePlayers"", ""id"": 1}", System.Text.Encoding.UTF8, "application/json"); 41 | string playerIDJSONResponse = _httpClient.PostAsync($"http://{_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}/jsonrpc", playerIDJSONRequest).Result.Content.ReadAsStringAsync().Result; 42 | 43 | using JsonDocument playerIdJSON = JsonDocument.Parse(playerIDJSONResponse); 44 | _playerID = playerIdJSON.RootElement.GetProperty("result")[0].GetProperty("playerid").GetRawText(); 45 | } 46 | 47 | StringContent filenameJSONRequest = new StringContent(@"{""jsonrpc"": ""2.0"", ""method"": ""Player.GetItem"", ""params"": {""properties"": [""file""], ""playerid"": 1}, ""id"": " + "1" + "}", System.Text.Encoding.UTF8, "application/json"); 48 | string filenameJSONResponse = _httpClient.PostAsync($"http://{_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}/jsonrpc", filenameJSONRequest).Result.Content.ReadAsStringAsync().Result; 49 | 50 | using JsonDocument fileInfoJSON = JsonDocument.Parse(filenameJSONResponse); 51 | string filePath = fileInfoJSON.RootElement.GetProperty("result").GetProperty("item").GetProperty("file").GetString(); 52 | 53 | (string, string) fileInfo = ParseKodiFile(filePath); 54 | FileName = fileInfo.Item1; 55 | FilePath = fileInfo.Item2; 56 | 57 | //for PlexKodiConnect plugin 58 | if (FileName.Contains("plex_id")) 59 | { 60 | FileName = FileName.Substring(FileName.LastIndexOf("&filename=") + 10); 61 | } 62 | 63 | //for Plex for Kodi plugin 64 | if(filePath.Contains("plex.direct")) 65 | { 66 | if(filePath.Contains("/transcode/")) 67 | { 68 | FileName = "Video being transcoded by Plex, only Direct Play (Original quality) supported"; 69 | } 70 | else 71 | { 72 | FileName = new string(fileInfoJSON.RootElement.GetProperty("result").GetProperty("item").GetProperty("label").GetString().Where(ch => !Path.GetInvalidFileNameChars().Contains(ch)).ToArray()); 73 | } 74 | } 75 | 76 | bool getKodiTime = true; 77 | 78 | if (_settings.MediaPlayerType == "KodiMPC") 79 | { 80 | try 81 | { 82 | string html = _httpClient.GetStringAsync($"http://{_settings.MediaPlayerIP}:13579/variables.html").Result; 83 | 84 | HtmlAgilityPack.HtmlDocument doc = new HtmlAgilityPack.HtmlDocument(); 85 | doc.LoadHtml(html); 86 | 87 | VideoTime = long.Parse(doc.GetElementbyId("position").InnerText); 88 | 89 | if (doc.GetElementbyId("statestring").InnerText == "Playing") 90 | { 91 | IsPlaying = true; 92 | } 93 | else 94 | { 95 | IsPlaying = false; 96 | } 97 | 98 | getKodiTime = false; 99 | } 100 | catch { } 101 | } 102 | 103 | if (getKodiTime) 104 | { 105 | StringContent timeJSONRequest = new StringContent(@"{""jsonrpc"": ""2.0"", ""method"": ""Player.GetProperties"", ""params"": {""properties"": [""time"", ""speed""], ""playerid"": 1}, ""id"": " + _playerID + "}", System.Text.Encoding.UTF8, "application/json"); 106 | string timeJSONResponse = _httpClient.PostAsync($"http://{_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}/jsonrpc", timeJSONRequest).Result.Content.ReadAsStringAsync().Result; 107 | 108 | using JsonDocument time = JsonDocument.Parse(timeJSONResponse); 109 | long hours = time.RootElement.GetProperty("result").GetProperty("time").GetProperty("hours").GetInt64(); 110 | long minutes = time.RootElement.GetProperty("result").GetProperty("time").GetProperty("minutes").GetInt64(); 111 | long seconds = time.RootElement.GetProperty("result").GetProperty("time").GetProperty("seconds").GetInt64(); 112 | long milliseconds = time.RootElement.GetProperty("result").GetProperty("time").GetProperty("milliseconds").GetInt64(); 113 | 114 | VideoTime = (hours * 3600000) + (minutes * 60000) + (seconds * 1000) + milliseconds + 200; 115 | 116 | using JsonDocument state = JsonDocument.Parse(timeJSONResponse); 117 | int stateNum = state.RootElement.GetProperty("result").GetProperty("speed").GetInt32(); 118 | 119 | if (stateNum == 1) 120 | { 121 | IsPlaying = true; 122 | } 123 | else 124 | { 125 | IsPlaying = false; 126 | } 127 | } 128 | } 129 | catch 130 | { 131 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Cannot connect to Kodi at: {_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}"; 132 | return false; 133 | } 134 | 135 | return true; 136 | } 137 | 138 | 139 | private static (string, string) ParseKodiFile(string filePathName) 140 | { 141 | string fileName; 142 | string decodedInput = HttpUtility.UrlDecode(HttpUtility.UrlDecode(filePathName)); 143 | 144 | if (filePathName.Contains("bluray:")) 145 | { 146 | string revInput = Reverse(decodedInput); 147 | 148 | int start = revInput.IndexOf("osi.") + 4; 149 | 150 | int end = revInput.IndexOf(@"\", start); 151 | if (end == -1) 152 | { 153 | end = revInput.IndexOf(@"/", start); 154 | } 155 | 156 | string revFilename = revInput[start..end]; 157 | 158 | fileName = Reverse(revFilename); 159 | } 160 | else 161 | { 162 | fileName = Path.GetFileNameWithoutExtension(filePathName); 163 | } 164 | 165 | string filePath; 166 | if (filePathName.Contains("bluray:")) 167 | { 168 | decodedInput = decodedInput.Replace(@"bluray://", ""); 169 | decodedInput = decodedInput.Replace(@"udf://", ""); 170 | decodedInput = decodedInput.Replace(@"smb:", ""); 171 | 172 | int end = decodedInput.IndexOf(fileName); 173 | 174 | filePath = decodedInput.Substring(0, end); 175 | } 176 | else 177 | { 178 | filePath = filePathName.Replace("smb:", ""); 179 | filePath = Path.GetDirectoryName(filePath); 180 | } 181 | 182 | return (fileName, filePath); 183 | } 184 | 185 | private static string Reverse(string s) 186 | { 187 | char[] charArray = s.ToCharArray(); 188 | Array.Reverse(charArray); 189 | return new string(charArray); 190 | } 191 | } 192 | } -------------------------------------------------------------------------------- /HTFanControl/Players/MPCPlayer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net.Http; 4 | using System.Text.Json; 5 | using System.Web; 6 | using HTFanControl.Util; 7 | 8 | namespace HTFanControl.Players 9 | { 10 | class MPCPlayer : IPlayer 11 | { 12 | private HttpClient _httpClient; 13 | private Settings _settings; 14 | private string _playerID = null; 15 | 16 | public bool IsPlaying { get; private set; } 17 | public long VideoTime { get; private set; } 18 | public string FileName { get; private set; } 19 | public string FilePath { get; private set; } 20 | public string ErrorStatus { get; private set; } 21 | public int VideoTimeResolution { get; private set; } 22 | 23 | public MPCPlayer(Settings settings) 24 | { 25 | VideoTimeResolution = 50; 26 | 27 | _settings = settings; 28 | 29 | _httpClient = new HttpClient(); 30 | _httpClient.Timeout = TimeSpan.FromSeconds(1); 31 | } 32 | 33 | public bool Update() 34 | { 35 | try 36 | { 37 | string html = _httpClient.GetStringAsync($"http://{_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}/variables.html").Result; 38 | 39 | HtmlAgilityPack.HtmlDocument doc = new HtmlAgilityPack.HtmlDocument(); 40 | doc.LoadHtml(html); 41 | 42 | FileName = Path.GetFileNameWithoutExtension(doc.GetElementbyId("file").InnerText); 43 | FilePath = doc.GetElementbyId("filedir").InnerText; 44 | 45 | VideoTime = long.Parse(doc.GetElementbyId("position").InnerText); 46 | 47 | //Get file from Kodi if MPC looks like it is a mounted ISO. 48 | if (FilePath.Contains(@"\BDMV")) 49 | { 50 | GetFileFromKodi(); 51 | } 52 | 53 | if (doc.GetElementbyId("statestring").InnerText == "Playing") 54 | { 55 | IsPlaying = true; 56 | } 57 | else 58 | { 59 | IsPlaying = false; 60 | } 61 | } 62 | catch 63 | { 64 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Cannot connect to MPC at: {_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}"; 65 | return false; 66 | } 67 | 68 | return true; 69 | } 70 | 71 | private void GetFileFromKodi() 72 | { 73 | if (_playerID is null) 74 | { 75 | StringContent playerIDJSONRequest = new StringContent(@"{""jsonrpc"": ""2.0"", ""method"": ""Player.GetActivePlayers"", ""id"": 1}", System.Text.Encoding.UTF8, "application/json"); 76 | string playerIDJSONResponse = _httpClient.PostAsync($"http://{_settings.MediaPlayerIP}:8080/jsonrpc", playerIDJSONRequest).Result.Content.ReadAsStringAsync().Result; 77 | 78 | using JsonDocument playerIdJSON = JsonDocument.Parse(playerIDJSONResponse); 79 | _playerID = playerIdJSON.RootElement.GetProperty("result")[0].GetProperty("playerid").GetRawText(); 80 | } 81 | 82 | StringContent filenameJSONRequest = new StringContent(@"{""jsonrpc"": ""2.0"", ""method"": ""Player.GetItem"", ""params"": {""properties"": [""file""], ""playerid"": 1}, ""id"": " + _playerID + "}", System.Text.Encoding.UTF8, "application/json"); 83 | 84 | HttpClient httpClient = new HttpClient(); 85 | httpClient.Timeout = TimeSpan.FromSeconds(1); 86 | 87 | string filenameJSONResponse = httpClient.PostAsync($"http://{_settings.MediaPlayerIP}:8080/jsonrpc", filenameJSONRequest).Result.Content.ReadAsStringAsync().Result; 88 | 89 | using JsonDocument fileInfoJSON = JsonDocument.Parse(filenameJSONResponse); 90 | string kodiFile = fileInfoJSON.RootElement.GetProperty("result").GetProperty("item").GetProperty("file").GetString(); 91 | 92 | (string, string) fileInfo = ParseKodiFile(kodiFile); 93 | 94 | FileName = fileInfo.Item1; 95 | FilePath = fileInfo.Item2; 96 | } 97 | 98 | private static (string, string) ParseKodiFile(string filePathName) 99 | { 100 | string fileName; 101 | string decodedInput = HttpUtility.UrlDecode(HttpUtility.UrlDecode(filePathName)); 102 | 103 | if (filePathName.Contains("bluray:")) 104 | { 105 | string revInput = Reverse(decodedInput); 106 | 107 | int start = revInput.IndexOf("osi.") + 4; 108 | 109 | int end = revInput.IndexOf(@"\", start); 110 | if (end == -1) 111 | { 112 | end = revInput.IndexOf(@"/", start); 113 | } 114 | 115 | string revFilename = revInput[start..end]; 116 | 117 | fileName = Reverse(revFilename); 118 | } 119 | else 120 | { 121 | fileName = Path.GetFileNameWithoutExtension(filePathName); 122 | } 123 | 124 | string filePath; 125 | if (filePathName.Contains("bluray:")) 126 | { 127 | decodedInput = decodedInput.Replace(@"bluray://", ""); 128 | decodedInput = decodedInput.Replace(@"udf://", ""); 129 | decodedInput = decodedInput.Replace(@"smb:", ""); 130 | 131 | int end = decodedInput.IndexOf(fileName); 132 | 133 | filePath = decodedInput.Substring(0, end); 134 | } 135 | else 136 | { 137 | filePath = filePathName.Replace("smb:", ""); 138 | filePath = Path.GetDirectoryName(filePath); 139 | } 140 | 141 | return (fileName, filePath); 142 | } 143 | 144 | private static string Reverse(string s) 145 | { 146 | char[] charArray = s.ToCharArray(); 147 | Array.Reverse(charArray); 148 | return new string(charArray); 149 | } 150 | } 151 | } -------------------------------------------------------------------------------- /HTFanControl/Players/PlexPlayer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Net.Http.Headers; 6 | using System.Xml.Linq; 7 | using HTFanControl.Util; 8 | 9 | namespace HTFanControl.Players 10 | { 11 | class PlexPlayer : IPlayer 12 | { 13 | private HttpClient _httpClient; 14 | 15 | private Settings _settings; 16 | 17 | public bool IsPlaying { get; private set; } 18 | public long VideoTime { get; private set; } 19 | public string FileName { get; private set; } 20 | public string FilePath { get; private set; } 21 | public string ErrorStatus { get; private set; } 22 | public int VideoTimeResolution { get; private set; } 23 | 24 | private string _pollingType = "1"; 25 | 26 | public PlexPlayer(Settings settings) 27 | { 28 | VideoTimeResolution = 1000; 29 | 30 | _settings = settings; 31 | 32 | _httpClient = new HttpClient(); 33 | _httpClient.Timeout = TimeSpan.FromSeconds(30); 34 | _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); 35 | _httpClient.DefaultRequestHeaders.Add("X-Plex-Client-Identifier", "HTFanControl"); 36 | _httpClient.DefaultRequestHeaders.Add("X-Plex-Device-Name", "HTFanControl"); 37 | _httpClient.DefaultRequestHeaders.Add("X-Plex-Target-Client-Identifier", _settings.PlexClientGUID); 38 | } 39 | 40 | public bool Update() 41 | { 42 | try 43 | { 44 | using Stream timeStream = _httpClient.GetAsync($"http://{_settings.PlexClientIP}:{_settings.PlexClientPort}/player/timeline/poll?wait={_pollingType}&protocol=http&port=5501").Result.Content.ReadAsStreamAsync().Result; 45 | _pollingType = "0"; 46 | XDocument timeXML = XDocument.Load(timeStream); 47 | XElement video = timeXML.Descendants("Timeline").Where(x => x.Attribute("type").Value == "video").First(); 48 | 49 | VideoTime = long.Parse(video.Attribute("time").Value) + 500; 50 | 51 | string state = video.Attribute("state").Value; 52 | string fileKey = video.Attribute("ratingKey").Value; 53 | 54 | using Stream fileStream = _httpClient.GetAsync($"http://{_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}/library/metadata/{fileKey}?X-Plex-Token={_settings.PlexToken}").Result.Content.ReadAsStreamAsync().Result; 55 | XDocument fileXML = XDocument.Load(fileStream); 56 | XElement media = fileXML.Descendants("MediaContainer").Descendants("Video").Descendants("Media").Descendants("Part").First(); 57 | 58 | FileName = Path.GetFileNameWithoutExtension(media.Attribute("file").Value); 59 | FilePath = Path.GetDirectoryName(media.Attribute("file").Value); 60 | 61 | if (state == "playing") 62 | { 63 | IsPlaying = true; 64 | } 65 | else 66 | { 67 | IsPlaying = false; 68 | } 69 | } 70 | catch 71 | { 72 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Cannot connect to Plex Player: {_settings.PlexClientName}"; 73 | _pollingType = "1"; 74 | return false; 75 | } 76 | 77 | return true; 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /HTFanControl/Players/RokuPlexPlayer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Net.Http.Headers; 7 | using System.Text.RegularExpressions; 8 | using System.Xml.Linq; 9 | using HTFanControl.Util; 10 | 11 | namespace HTFanControl.Players 12 | { 13 | class RokuPlexPlayer : IPlayer 14 | { 15 | private HttpClient _httpClient; 16 | private Settings _settings; 17 | private string _pollingType = "1"; 18 | 19 | public bool IsPlaying { get; private set; } 20 | public long VideoTime { get; private set; } 21 | public string FileName { get; private set; } 22 | public string FilePath { get; private set; } 23 | public string ErrorStatus { get; private set; } 24 | public int VideoTimeResolution { get; private set; } 25 | 26 | public RokuPlexPlayer(Settings settings) 27 | { 28 | VideoTimeResolution = 50; 29 | 30 | _settings = settings; 31 | 32 | _httpClient = new HttpClient(); 33 | _httpClient.Timeout = TimeSpan.FromSeconds(30); 34 | _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); 35 | _httpClient.DefaultRequestHeaders.Add("X-Plex-Client-Identifier", "HTFanControl"); 36 | _httpClient.DefaultRequestHeaders.Add("X-Plex-Device-Name", "HTFanControl"); 37 | _httpClient.DefaultRequestHeaders.Add("X-Plex-Target-Client-Identifier", _settings.PlexClientGUID); 38 | } 39 | 40 | public bool Update() 41 | { 42 | string fileKey = ""; 43 | 44 | try 45 | { 46 | using Stream timeStream = _httpClient.GetAsync($"http://{_settings.PlexClientIP}:{_settings.PlexClientPort}/player/timeline/poll?wait={_pollingType}&protocol=http&port=5501").Result.Content.ReadAsStreamAsync().Result; 47 | _pollingType = "0"; 48 | XDocument timeXML = XDocument.Load(timeStream); 49 | XElement video = timeXML.Descendants("Timeline").Where(x => x.Attribute("type").Value == "video").First(); 50 | fileKey = video.Attribute("ratingKey").Value; 51 | } 52 | catch 53 | { 54 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Cannot connect to Roku Plex App: {_settings.PlexClientName} ({_settings.PlexClientIP}:{_settings.PlexClientPort})"; 55 | _pollingType = "1"; 56 | return false; 57 | } 58 | 59 | try 60 | { 61 | using Stream fileStream = _httpClient.GetAsync($"http://{_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}/library/metadata/{fileKey}?X-Plex-Token={_settings.PlexToken}").Result.Content.ReadAsStreamAsync().Result; 62 | XDocument fileXML = XDocument.Load(fileStream); 63 | XElement media = fileXML.Descendants("MediaContainer").Descendants("Video").Descendants("Media").Descendants("Part").First(); 64 | 65 | FileName = Path.GetFileNameWithoutExtension(media.Attribute("file").Value); 66 | FilePath = Path.GetDirectoryName(media.Attribute("file").Value); 67 | 68 | } 69 | catch 70 | { 71 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Cannot connect to Plex Media Server at: {_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}"; 72 | _pollingType = "1"; 73 | return false; 74 | } 75 | 76 | try 77 | { 78 | Stopwatch sw = new Stopwatch(); 79 | sw.Start(); 80 | 81 | using Stream rokuStream = _httpClient.GetAsync($"http://{_settings.PlexClientIP}:8060/query/media-player").Result.Content.ReadAsStreamAsync().Result; 82 | XDocument rokuXML = XDocument.Load(rokuStream); 83 | string strPosition = rokuXML.Descendants().First().Descendants("position").First().Value; 84 | 85 | //Roku API seems to be somewhat slow and a bit inconsistent, so we add some time to the returned time to compensate. 86 | VideoTime = Convert.ToInt64(Regex.Replace(strPosition, "[^0-9.]", "")) + 250 + sw.ElapsedMilliseconds; 87 | sw.Stop(); 88 | 89 | string state = rokuXML.Root.Attribute("state").Value; 90 | 91 | if (state == "play") 92 | { 93 | IsPlaying = true; 94 | } 95 | else 96 | { 97 | IsPlaying = false; 98 | } 99 | } 100 | catch 101 | { 102 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Cannot connect to Roku Media Player: {_settings.PlexClientName} ({_settings.PlexClientIP}:8060)"; 103 | return false; 104 | } 105 | 106 | return true; 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /HTFanControl/Players/ZidooPlayer.cs: -------------------------------------------------------------------------------- 1 | using HTFanControl.Util; 2 | using System; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Net.Http; 7 | using System.Text.Json; 8 | 9 | namespace HTFanControl.Players 10 | { 11 | class ZidooPlayer : IPlayer 12 | { 13 | private HttpClient _httpClient; 14 | private Settings _settings; 15 | 16 | public bool IsPlaying { get; private set; } 17 | public long VideoTime { get; private set; } 18 | public string FileName { get; private set; } 19 | public string FilePath { get; private set; } 20 | public string ErrorStatus { get; private set; } 21 | public int VideoTimeResolution { get; private set; } 22 | 23 | public ZidooPlayer(Settings settings) 24 | { 25 | VideoTimeResolution = 50; 26 | 27 | _settings = settings; 28 | 29 | _httpClient = new HttpClient(); 30 | _httpClient.Timeout = TimeSpan.FromSeconds(1); 31 | } 32 | 33 | public bool Update() 34 | { 35 | try 36 | { 37 | Stopwatch sw = new Stopwatch(); 38 | sw.Start(); 39 | 40 | string playerStatusJSONResponse = _httpClient.GetStringAsync($"http://{_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}/ZidooVideoPlay/getPlayStatus").Result; 41 | using JsonDocument playerstatusJSON = JsonDocument.Parse(playerStatusJSONResponse); 42 | 43 | VideoTime = playerstatusJSON.RootElement.GetProperty("video").GetProperty("currentPosition").GetInt32() + sw.ElapsedMilliseconds; 44 | sw.Stop(); 45 | 46 | FilePath = playerstatusJSON.RootElement.GetProperty("video").GetProperty("path").GetString(); 47 | try 48 | { 49 | FileName = Path.GetFileNameWithoutExtension(FilePath); 50 | } 51 | catch { } 52 | 53 | if(string.IsNullOrEmpty(FileName)) 54 | { 55 | FileName = new string(playerstatusJSON.RootElement.GetProperty("video").GetProperty("title").GetString().Where(ch => !Path.GetInvalidFileNameChars().Contains(ch)).ToArray()); 56 | } 57 | 58 | int playState = playerstatusJSON.RootElement.GetProperty("video").GetProperty("status").GetInt32(); 59 | 60 | if (playState == 1) 61 | { 62 | IsPlaying = true; 63 | } 64 | else if (playState == 0) 65 | { 66 | IsPlaying = false; 67 | } 68 | } 69 | catch 70 | { 71 | ErrorStatus = $"({DateTime.Now:h:mm:ss tt}) Cannot connect to Zidoo at: {_settings.MediaPlayerIP}:{_settings.MediaPlayerPort}"; 72 | return false; 73 | } 74 | 75 | return true; 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /HTFanControl/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Reflection; 5 | using System.Threading.Tasks; 6 | using HTFanControl.Util; 7 | 8 | namespace HTFanControl 9 | { 10 | class Program 11 | { 12 | private static string port = "5500"; 13 | private static string instanceName = "HTFanControl"; 14 | 15 | static void Main(string[] args) 16 | { 17 | try 18 | { 19 | if (!string.IsNullOrEmpty(args[0])) 20 | { 21 | port = args[0]; 22 | } 23 | if (!string.IsNullOrEmpty(args[1])) 24 | { 25 | instanceName = args[1]; 26 | } 27 | } 28 | catch { } 29 | 30 | 31 | if (Process.GetProcessesByName(Path.GetFileNameWithoutExtension(Assembly.GetEntryAssembly().Location)).Length > 1 && port == "5500") 32 | { 33 | Process.GetCurrentProcess().Kill(); 34 | } 35 | 36 | AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException); 37 | 38 | if (!Directory.Exists(Path.Combine(ConfigHelper._rootPath, "windtracks"))) 39 | { 40 | Directory.CreateDirectory(Path.Combine(ConfigHelper._rootPath, "windtracks")); 41 | } 42 | 43 | if (!Directory.Exists(Path.Combine(ConfigHelper._rootPath, "tmp"))) 44 | { 45 | Directory.CreateDirectory(Path.Combine(ConfigHelper._rootPath, "tmp")); 46 | } 47 | 48 | if (ConfigHelper.OS == "win") 49 | { 50 | ConfigHelper.SetupWin(port, instanceName); 51 | #if (RELEASE || DEBUG) 52 | Task.Factory.StartNew(() => new FanTrayIcon.TrayIcon(port, instanceName)); 53 | #endif 54 | } 55 | else 56 | { 57 | ConfigHelper.SetupLinux(); 58 | } 59 | 60 | _ = new Main.WebUI(port, instanceName); 61 | } 62 | 63 | static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) 64 | { 65 | Exception ex = e.ExceptionObject as Exception; 66 | 67 | if (!Directory.Exists(Path.Combine(ConfigHelper._rootPath, "crashlogs"))) 68 | { 69 | Directory.CreateDirectory(Path.Combine(ConfigHelper._rootPath, "crashlogs")); 70 | } 71 | 72 | string crash = ex.Message + "\n\n" + ex.InnerException + "\n\n" + ex.Source + "\n\n" + ex.StackTrace; 73 | File.WriteAllText(Path.Combine(ConfigHelper._rootPath, "crashlogs", DateTime.Now.ToString("MM.dd.yy-hh.mm-tt") + ".txt"), crash); 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /HTFanControl/Properties/PublishProfiles/linux.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | ReleaseLinux 8 | Any CPU 9 | bin\Publish\linux 10 | FileSystem 11 | net5.0-windows7.0 12 | linux-x64 13 | true 14 | True 15 | True 16 | 17 | -------------------------------------------------------------------------------- /HTFanControl/Properties/PublishProfiles/raspi.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | ReleaseLinux 8 | Any CPU 9 | bin\Publish\raspi 10 | FileSystem 11 | linux-arm 12 | true 13 | true 14 | true 15 | 16 | -------------------------------------------------------------------------------- /HTFanControl/Properties/PublishProfiles/raspi64.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | ReleaseLinux 8 | Any CPU 9 | bin\Publish\raspi64 10 | FileSystem 11 | net5.0-windows7.0 12 | linux-arm64 13 | true 14 | True 15 | True 16 | 17 | -------------------------------------------------------------------------------- /HTFanControl/Properties/PublishProfiles/win.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | ReleaseWin 8 | Any CPU 9 | bin\Publish\win 10 | FileSystem 11 | win-x64 12 | true 13 | true 14 | true 15 | 16 | -------------------------------------------------------------------------------- /HTFanControl/Timers/PositionTimer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | 8 | namespace HTFanControl.Timers 9 | { 10 | internal abstract class PositionTimer 11 | { 12 | public abstract bool Update(TimeSpan currentPosition); 13 | public abstract bool Stop(); 14 | public abstract bool TryGetNextPositions(out ReadOnlySpan nextPositions); 15 | public abstract ValueTask DisposeAsync(bool stop); 16 | 17 | protected static long Delta(TimeSpan t1, TimeSpan t2) 18 | { 19 | var deltaTicks = Subtract(t1, t2); 20 | 21 | if (deltaTicks >= 0) 22 | return deltaTicks; 23 | 24 | deltaTicks = unchecked(-deltaTicks); 25 | return deltaTicks > 0 ? deltaTicks : long.MaxValue; 26 | } 27 | 28 | protected static long Add(TimeSpan t1, TimeSpan t2) 29 | { 30 | var t1Ticks = t1.Ticks; 31 | var t2Ticks = t2.Ticks; 32 | var resultTicks = unchecked(t1Ticks + t2Ticks); 33 | 34 | if (t1Ticks >> 63 == t2Ticks >> 63 && t1Ticks >> 63 != resultTicks >> 63) 35 | return long.MaxValue; 36 | 37 | return resultTicks; 38 | } 39 | 40 | protected static long Subtract(TimeSpan t1, TimeSpan t2) 41 | { 42 | var t1Ticks = t1.Ticks; 43 | var t2Ticks = t2.Ticks; 44 | var resultTicks = unchecked(t1Ticks - t2Ticks); 45 | 46 | if (t1Ticks >> 63 != t2Ticks >> 63 && t1Ticks >> 63 != resultTicks >> 63) 47 | return long.MaxValue; 48 | 49 | return resultTicks; 50 | } 51 | } 52 | 53 | internal class PositionTimer : PositionTimer 54 | { 55 | private readonly ExecutionContext _executionContext; 56 | private readonly TimeSpan[] _positions; 57 | private readonly T[] _values; 58 | private readonly T _defaultValue; 59 | private readonly long _currentPositionToleranceExclusiveTicks; 60 | private readonly long _skipWindowTicks; 61 | private readonly Stopwatch _stopwatch; 62 | 63 | private const long TicksPerMillisecond = 10000; 64 | private const long SkipWindowMultiplier = 5; 65 | private const long ElapsedPositionToleranceTicks = 20 * TicksPerMillisecond; 66 | private const long MinAdjustedIntervalTicks = 60000 * TicksPerMillisecond; 67 | private const double AdjustmentFraction = 0.8; 68 | 69 | private Action _action; 70 | private Timer _timer; 71 | private TimeSpan _startPosition; 72 | private int _index; 73 | private TimeSpan _nextPosition; 74 | private T _lastValue; 75 | private bool _invoking; 76 | private TaskCompletionSource _disposed; 77 | 78 | public PositionTimer(IEnumerable<(TimeSpan position, T value)> values, Action action, 79 | int millisecondsCurrentPositionResolution, T defaultValue = default) 80 | { 81 | if (millisecondsCurrentPositionResolution <= 0) 82 | { 83 | throw new ArgumentOutOfRangeException(nameof(millisecondsCurrentPositionResolution), 84 | "Must be greater than 0"); 85 | } 86 | 87 | _executionContext = ExecutionContext.Capture(); 88 | 89 | var orderedValues = values.OrderBy(static v => v.position).ToList(); 90 | var count = orderedValues.Count; 91 | _positions = new TimeSpan[count]; 92 | _values = new T[count]; 93 | TimeSpan lastPosition = default; 94 | var lastValue = defaultValue; 95 | var distinct = 0; 96 | for (var i = 0; i < count; i++) 97 | { 98 | var (position, value) = orderedValues[i]; 99 | if (position == lastPosition && distinct > 0) 100 | { 101 | lastValue = distinct > 1 ? _values[distinct - 2] : defaultValue; 102 | if (EqualityComparer.Default.Equals(value, lastValue)) 103 | { 104 | lastPosition = TimeSpan.MinValue; 105 | distinct--; 106 | } 107 | else 108 | { 109 | _values[distinct - 1] = value; 110 | lastValue = value; 111 | } 112 | } 113 | else if (!EqualityComparer.Default.Equals(value, lastValue)) 114 | { 115 | _positions[distinct] = position; 116 | _values[distinct] = value; 117 | lastPosition = position; 118 | lastValue = value; 119 | distinct++; 120 | } 121 | } 122 | 123 | Array.Resize(ref _positions, distinct); 124 | Array.Resize(ref _values, distinct); 125 | 126 | _action = action ?? throw new ArgumentNullException(nameof(action)); 127 | _defaultValue = defaultValue; 128 | _currentPositionToleranceExclusiveTicks = millisecondsCurrentPositionResolution * TicksPerMillisecond; 129 | _skipWindowTicks = _currentPositionToleranceExclusiveTicks * SkipWindowMultiplier; 130 | _stopwatch = new Stopwatch(); 131 | 132 | InvokeTimerCallback(); 133 | } 134 | 135 | public override bool Update(TimeSpan currentPosition) 136 | { 137 | lock (_positions) 138 | { 139 | if (_timer != null) 140 | { 141 | var timerPosition = GetCurrentPosition(); 142 | var deltaTicks = Delta(timerPosition, currentPosition); 143 | 144 | if (deltaTicks < _currentPositionToleranceExclusiveTicks) 145 | return true; 146 | 147 | _stopwatch.Restart(); 148 | 149 | if (currentPosition < timerPosition && deltaTicks <= _skipWindowTicks && 150 | Subtract(currentPosition, _startPosition) > _skipWindowTicks) 151 | { 152 | if (!_invoking && currentPosition < _nextPosition) 153 | Change(currentPosition); 154 | } 155 | else 156 | { 157 | UpdateStateAndChangeOrInvoke(currentPosition); 158 | } 159 | } 160 | else if (_action != null && _disposed == null) 161 | { 162 | _stopwatch.Restart(); 163 | 164 | if (_positions.Length > 0) 165 | { 166 | if (!ExecutionContext.IsFlowSuppressed()) 167 | { 168 | using (ExecutionContext.SuppressFlow()) 169 | _timer = new Timer(TimerCallback); 170 | } 171 | else 172 | { 173 | _timer = new Timer(TimerCallback); 174 | } 175 | 176 | UpdateStateAndChangeOrInvoke(currentPosition); 177 | } 178 | } 179 | else 180 | { 181 | return false; 182 | } 183 | 184 | _startPosition = currentPosition; 185 | } 186 | 187 | return true; 188 | } 189 | 190 | public override bool Stop() 191 | { 192 | lock (_positions) 193 | { 194 | _stopwatch.Reset(); 195 | 196 | if (_timer == null) 197 | return _action != null && _disposed == null; 198 | 199 | _timer.Dispose(); 200 | _timer = null; 201 | 202 | if (!_invoking && !EqualityComparer.Default.Equals(_lastValue, _defaultValue)) 203 | InvokeTimerCallback(); 204 | } 205 | 206 | return true; 207 | } 208 | 209 | public override bool TryGetNextPositions(out ReadOnlySpan nextPositions) 210 | { 211 | int nextIndex; 212 | lock (_positions) 213 | { 214 | if (_timer == null) 215 | { 216 | nextPositions = default; 217 | return _stopwatch.IsRunning; 218 | } 219 | 220 | var currentPosition = GetCurrentPosition(); 221 | if (currentPosition < _nextPosition) 222 | { 223 | nextIndex = _index; 224 | if (EqualityComparer.Default.Equals(_lastValue, _values[nextIndex])) 225 | nextIndex++; 226 | } 227 | else if (currentPosition < _positions[0]) 228 | { 229 | nextIndex = 0; 230 | } 231 | else 232 | { 233 | nextIndex = _index; 234 | if (EqualityComparer.Default.Equals(_lastValue, 235 | UpdateIndexAndGetValue(currentPosition, ref nextIndex))) 236 | { 237 | nextIndex++; 238 | } 239 | } 240 | } 241 | 242 | nextPositions = new ReadOnlySpan(_positions, nextIndex, _positions.Length - nextIndex); 243 | return true; 244 | } 245 | 246 | public override ValueTask DisposeAsync(bool stop) 247 | { 248 | lock (_positions) 249 | { 250 | if (_action == null || _disposed != null) 251 | return default; 252 | 253 | if (stop) 254 | { 255 | _timer?.Dispose(); 256 | _timer = null; 257 | _stopwatch.Reset(); 258 | 259 | if (!_invoking) 260 | { 261 | if (EqualityComparer.Default.Equals(_lastValue, _defaultValue)) 262 | { 263 | _action = null; 264 | return new ValueTask(true); 265 | } 266 | 267 | InvokeTimerCallback(); 268 | } 269 | } 270 | else 271 | { 272 | Interlocked.Exchange(ref _action, null); 273 | _timer?.Dispose(); 274 | _timer = null; 275 | _stopwatch.Reset(); 276 | 277 | if (!_invoking) 278 | return new ValueTask(true); 279 | } 280 | 281 | _disposed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); 282 | } 283 | 284 | return new ValueTask(_disposed.Task); 285 | } 286 | 287 | private void TimerCallback(object state) 288 | { 289 | if (state != null) 290 | { 291 | if (state != _timer) 292 | return; 293 | 294 | lock (_positions) 295 | { 296 | if (_timer != state || _invoking) 297 | return; 298 | 299 | var currentPosition = GetCurrentPosition(); 300 | 301 | TimeSpan elapsedPosition; 302 | if (currentPosition < _nextPosition) 303 | { 304 | if (Subtract(_nextPosition, currentPosition) > ElapsedPositionToleranceTicks) 305 | { 306 | Change(currentPosition); 307 | return; 308 | } 309 | 310 | elapsedPosition = _nextPosition; 311 | } 312 | else 313 | { 314 | elapsedPosition = currentPosition; 315 | } 316 | 317 | var currentValue = UpdateIndexAndGetValue(elapsedPosition, ref _index); 318 | UpdateNextPosition(elapsedPosition); 319 | 320 | if (EqualityComparer.Default.Equals(_lastValue, currentValue)) 321 | { 322 | if (elapsedPosition < _nextPosition) 323 | Change(currentPosition); 324 | 325 | return; 326 | } 327 | 328 | _lastValue = currentValue; 329 | _invoking = true; 330 | } 331 | } 332 | 333 | if (_executionContext != null) 334 | { 335 | ExecutionContext.Run(_executionContext, static state => 336 | { 337 | var timer = (PositionTimer) state; 338 | Volatile.Read(ref timer._action)?.Invoke(timer, timer._lastValue); 339 | }, this); 340 | } 341 | else 342 | { 343 | Volatile.Read(ref _action)?.Invoke(this, _lastValue); 344 | } 345 | 346 | lock (_positions) 347 | { 348 | _invoking = false; 349 | 350 | if (_timer != null) 351 | { 352 | var currentPosition = GetCurrentPosition(); 353 | if (currentPosition < _nextPosition) 354 | { 355 | Change(currentPosition); 356 | } 357 | else 358 | { 359 | UpdateStateAndChangeOrInvoke(currentPosition); 360 | } 361 | } 362 | else if (!EqualityComparer.Default.Equals(_lastValue, _defaultValue) && _action != null) 363 | { 364 | InvokeTimerCallback(); 365 | } 366 | else 367 | { 368 | _disposed?.SetResult(true); 369 | } 370 | } 371 | } 372 | 373 | private TimeSpan GetCurrentPosition() => new TimeSpan(Add(_startPosition, _stopwatch.Elapsed)); 374 | 375 | private void UpdateStateAndChangeOrInvoke(TimeSpan currentPosition) 376 | { 377 | if (_invoking) 378 | { 379 | _nextPosition = TimeSpan.MinValue; 380 | } 381 | else if (EqualityComparer.Default.Equals(_lastValue, UpdateIndexAndGetValue(currentPosition, ref _index))) 382 | { 383 | UpdateNextPosition(currentPosition); 384 | 385 | if (currentPosition < _nextPosition) 386 | Change(currentPosition); 387 | } 388 | else 389 | { 390 | InvokeTimerCallback(); 391 | } 392 | } 393 | 394 | private void Change(TimeSpan currentPosition) 395 | { 396 | _timer.Change(Math.Min((long) GetInterval(currentPosition, _nextPosition).TotalMilliseconds, 4294967294), 397 | Timeout.Infinite); 398 | } 399 | 400 | private void InvokeTimerCallback() 401 | { 402 | if (_timer == null) 403 | { 404 | _lastValue = _defaultValue; 405 | _invoking = true; 406 | ThreadPool.UnsafeQueueUserWorkItem(static state => ((PositionTimer) state).TimerCallback(null), 407 | this); 408 | } 409 | else 410 | { 411 | _nextPosition = TimeSpan.MinValue; 412 | _timer.Change(0, Timeout.Infinite); 413 | } 414 | } 415 | 416 | private T UpdateIndexAndGetValue(TimeSpan currentPosition, ref int index) 417 | { 418 | if (currentPosition >= _positions[index]) 419 | { 420 | if (index == _positions.Length - 1 || currentPosition < _positions[index + 1]) 421 | return _values[index]; 422 | 423 | index++; 424 | 425 | if (index != _positions.Length - 1 && currentPosition >= _positions[index + 1]) 426 | index = BinarySearch(_positions, index + 1, _positions.Length - 1, currentPosition); 427 | } 428 | else if (currentPosition < _positions[0]) 429 | { 430 | index = 0; 431 | return _defaultValue; 432 | } 433 | else 434 | { 435 | index = BinarySearch(_positions, 1, index - 1, currentPosition); 436 | } 437 | 438 | return _values[index]; 439 | } 440 | 441 | private void UpdateNextPosition(TimeSpan currentPosition) 442 | { 443 | if (_index == 0 && currentPosition < _positions[0]) 444 | { 445 | _nextPosition = _positions[0]; 446 | } 447 | else if (_index == _positions.Length - 1) 448 | { 449 | _nextPosition = _positions[_index]; 450 | } 451 | else 452 | { 453 | _nextPosition = _positions[_index + 1]; 454 | } 455 | } 456 | 457 | private static int BinarySearch(TimeSpan[] array, int lowIndex, int highIndex, TimeSpan value) 458 | { 459 | while (lowIndex <= highIndex) 460 | { 461 | var i = lowIndex + ((highIndex - lowIndex) >> 1); 462 | var order = array[i].CompareTo(value); 463 | 464 | if (order == 0) 465 | return i; 466 | 467 | if (order < 0) 468 | { 469 | lowIndex = i + 1; 470 | } 471 | else 472 | { 473 | highIndex = i - 1; 474 | } 475 | } 476 | 477 | return highIndex; 478 | } 479 | 480 | private static TimeSpan GetInterval(TimeSpan startPosition, TimeSpan endPosition) 481 | { 482 | var intervalTicks = Subtract(endPosition, startPosition); 483 | 484 | if (intervalTicks <= MinAdjustedIntervalTicks) 485 | return new TimeSpan(intervalTicks); 486 | 487 | var adjustedIntervalTicks = (long) Math.Ceiling(intervalTicks * AdjustmentFraction); 488 | return adjustedIntervalTicks < MinAdjustedIntervalTicks 489 | ? new TimeSpan(intervalTicks) 490 | : new TimeSpan(adjustedIntervalTicks); 491 | } 492 | } 493 | } 494 | -------------------------------------------------------------------------------- /HTFanControl/Util/ConfigHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.IO; 3 | 4 | namespace HTFanControl.Util 5 | { 6 | public static class ConfigHelper 7 | { 8 | public static readonly string _rootPath = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName); 9 | 10 | public static string OS 11 | { 12 | get 13 | { 14 | string OS = System.Runtime.InteropServices.RuntimeInformation.OSDescription; 15 | 16 | if (OS.Contains("Windows")) 17 | { 18 | OS = "win"; 19 | } 20 | else if (OS.Contains("raspi")) 21 | { 22 | OS = "raspi"; 23 | } 24 | else 25 | { 26 | OS = "linux"; 27 | } 28 | 29 | return OS; 30 | } 31 | } 32 | 33 | public static void SetupWin(string port, string instanceName) 34 | { 35 | string adminCMD = null; 36 | string firewall = RunCmd("netsh", $"advfirewall firewall show rule name={instanceName}", false); 37 | if (!firewall.Contains(instanceName)) 38 | { 39 | adminCMD = $"netsh advfirewall firewall add rule name=\"{instanceName}\" protocol=TCP dir=in localport={port} action=allow"; 40 | } 41 | 42 | string urlacl = RunCmd("netsh", $"http show urlacl url=http://*:{port}/", false); 43 | if (!urlacl.Contains($"http://*:{port}/")) 44 | { 45 | if (adminCMD != null) 46 | { 47 | adminCMD += " && "; 48 | } 49 | adminCMD += $"netsh http add urlacl url=http://*:{port}/ user=%computername%\\%username%"; 50 | } 51 | 52 | if (adminCMD != null) 53 | { 54 | RunCmd("cmd", "/C " + adminCMD, true); 55 | } 56 | 57 | WinRegistry.FixMSEdge(); 58 | WinRegistry.SetMPCHCTimerInterval(); 59 | } 60 | 61 | public static void SetupLinux() 62 | { 63 | $"chmod -R 7777 {_rootPath}".Bash(); 64 | } 65 | 66 | public static string RunCmd(string filename, string arguments, bool admin) 67 | { 68 | Process process = new Process(); 69 | process.StartInfo.FileName = filename; 70 | process.StartInfo.Arguments = arguments; 71 | process.StartInfo.CreateNoWindow = true; 72 | 73 | if (admin) 74 | { 75 | process.StartInfo.Verb = "runas"; 76 | process.StartInfo.UseShellExecute = true; 77 | } 78 | else 79 | { 80 | process.StartInfo.UseShellExecute = false; 81 | process.StartInfo.RedirectStandardOutput = true; 82 | } 83 | 84 | string output = null; 85 | try 86 | { 87 | process.Start(); 88 | if (!admin) 89 | { 90 | output = process.StandardOutput.ReadToEnd(); 91 | } 92 | process.WaitForExit(); 93 | } 94 | catch { } 95 | 96 | return output; 97 | } 98 | } 99 | 100 | public static class ShellHelper 101 | { 102 | public static string Bash(this string cmd) 103 | { 104 | string escapedArgs = cmd.Replace("\"", "\\\""); 105 | 106 | Process process = new Process() 107 | { 108 | StartInfo = new ProcessStartInfo 109 | { 110 | FileName = "/bin/bash", 111 | Arguments = $"-c \"{escapedArgs}\"", 112 | RedirectStandardOutput = true, 113 | UseShellExecute = false, 114 | CreateNoWindow = true, 115 | } 116 | }; 117 | process.Start(); 118 | string result = process.StandardOutput.ReadToEnd(); 119 | process.WaitForExit(); 120 | return result; 121 | } 122 | } 123 | } -------------------------------------------------------------------------------- /HTFanControl/Util/Log.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | 5 | namespace HTFanControl.Util 6 | { 7 | public class Log 8 | { 9 | private static readonly string _logPath = Path.Combine(ConfigHelper._rootPath, "eventlog.txt"); 10 | private static readonly string _traceLogPath = Path.Combine(ConfigHelper._rootPath, "tracelog.txt"); 11 | private static bool _traceLogEnabed = false; 12 | 13 | public LinkedList RealtimeLog { get; } = new LinkedList(); 14 | 15 | public Log() 16 | { 17 | try 18 | { 19 | File.Delete(_logPath); 20 | } 21 | catch { } 22 | 23 | if(File.Exists(_traceLogPath)) 24 | { 25 | _traceLogEnabed = true; 26 | } 27 | } 28 | 29 | public void LogMsg(string line) 30 | { 31 | string timestamp = $"[{DateTime.Now:hh:mm:ss.fff}]: "; 32 | 33 | Console.WriteLine($"{timestamp}{line}"); 34 | 35 | try 36 | { 37 | File.AppendAllText(_logPath, $"{timestamp}{line}{Environment.NewLine}"); 38 | } 39 | catch { } 40 | 41 | RealtimeLog.AddFirst($"{timestamp}{line}"); 42 | if(RealtimeLog.Count > 50) 43 | { 44 | RealtimeLog.RemoveLast(); 45 | } 46 | } 47 | 48 | public static void LogTrace(string line) 49 | { 50 | if (_traceLogEnabed) 51 | { 52 | string timestamp = $"[{DateTime.Now:hh:mm:ss.fff}]: "; 53 | 54 | try 55 | { 56 | File.AppendAllText(_traceLogPath, $"{timestamp}{line}{Environment.NewLine}"); 57 | } 58 | catch { } 59 | } 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /HTFanControl/Util/Settings.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Text.Json; 5 | using System.Text.Json.Serialization; 6 | 7 | namespace HTFanControl.Util 8 | { 9 | class Settings 10 | { 11 | public string MediaPlayerType { get; set; } 12 | public string MediaPlayerIP { get; set; } 13 | public int MediaPlayerPort { get; set; } 14 | public string ControllerType { get; set; } 15 | public string LIRC_IP { get; set; } 16 | public int LIRC_Port { get; set; } 17 | public string LIRC_Remote { get; set; } 18 | public int LIRC_ON_Delay { get; set; } 19 | public string MQTT_IP { get; set; } 20 | public int MQTT_Port { get; set; } 21 | public string MQTT_User { get; set; } 22 | public string MQTT_Pass { get; set; } 23 | public string MQTT_OFF_Topic { get; set; } 24 | public string MQTT_OFF_Payload { get; set; } 25 | public string MQTT_ECO_Topic { get; set; } 26 | public string MQTT_ECO_Payload { get; set; } 27 | public string MQTT_LOW_Topic { get; set; } 28 | public string MQTT_LOW_Payload { get; set; } 29 | public string MQTT_MED_Topic { get; set; } 30 | public string MQTT_MED_Payload { get; set; } 31 | public string MQTT_HIGH_Topic { get; set; } 32 | public string MQTT_HIGH_Payload { get; set; } 33 | public string MQTT_ON_Topic { get; set; } 34 | public string MQTT_ON_Payload { get; set; } 35 | public int MQTT_ON_Delay { get; set; } 36 | public bool MQTT_Advanced_Mode { get; set; } 37 | public Dictionary MQTT_Topics { get; set; } 38 | public Dictionary MQTT_Payloads { get; set; } 39 | public string HTTP_OFF_URL { get; set; } 40 | public string HTTP_OFF_URL2 { get; set; } 41 | public string HTTP_OFF_URL3 { get; set; } 42 | public string HTTP_OFF_URL4 { get; set; } 43 | public string HTTP_ECO_URL { get; set; } 44 | public string HTTP_ECO_URL2 { get; set; } 45 | public string HTTP_ECO_URL3 { get; set; } 46 | public string HTTP_ECO_URL4 { get; set; } 47 | public string HTTP_LOW_URL { get; set; } 48 | public string HTTP_LOW_URL2 { get; set; } 49 | public string HTTP_LOW_URL3 { get; set; } 50 | public string HTTP_LOW_URL4 { get; set; } 51 | public string HTTP_MED_URL { get; set; } 52 | public string HTTP_MED_URL2 { get; set; } 53 | public string HTTP_MED_URL3 { get; set; } 54 | public string HTTP_MED_URL4 { get; set; } 55 | public string HTTP_HIGH_URL { get; set; } 56 | public string HTTP_HIGH_URL2 { get; set; } 57 | public string HTTP_HIGH_URL3 { get; set; } 58 | public string HTTP_HIGH_URL4 { get; set; } 59 | public string AudioDevice { get; set; } 60 | public string PlexToken { get; set; } 61 | public string PlexClientName { get; set; } 62 | public string PlexClientIP { get; set; } 63 | public string PlexClientPort { get; set; } 64 | public string PlexClientGUID { get; set; } 65 | public int GlobalOffsetMS { get; set; } 66 | public int ECOSpinupOffsetMS { get; set; } 67 | public int LOWSpinupOffsetMS { get; set; } 68 | public int MEDSpinupOffsetMS { get; set; } 69 | public int HIGHSpinupOffsetMS { get; set; } 70 | public int SpindownOffsetMS { get; set; } 71 | 72 | public static Settings LoadSettings() 73 | { 74 | Settings settings = new Settings(); 75 | try 76 | { 77 | JsonSerializerOptions options = new JsonSerializerOptions(); 78 | options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault; 79 | options.WriteIndented = true; 80 | 81 | string jsonSettings = File.ReadAllText(Path.Combine(ConfigHelper._rootPath, "HTFanControlSettings.json")); 82 | settings = JsonSerializer.Deserialize(jsonSettings, options); 83 | } 84 | catch 85 | { 86 | //default values 87 | settings.MediaPlayerType = "Kodi"; 88 | settings.MediaPlayerIP = "192.168.1.100"; 89 | settings.MediaPlayerPort = 8080; 90 | settings.ControllerType = "MQTT"; 91 | settings.MQTT_IP = "127.0.0.1"; 92 | settings.MQTT_OFF_Topic = "cmnd/HTFan/EVENT"; 93 | settings.MQTT_OFF_Payload = "s0"; 94 | settings.MQTT_ECO_Topic = "cmnd/HTFan/EVENT"; 95 | settings.MQTT_ECO_Payload = "s1"; 96 | settings.MQTT_LOW_Topic = "cmnd/HTFan/EVENT"; 97 | settings.MQTT_LOW_Payload = "s2"; 98 | settings.MQTT_MED_Topic = "cmnd/HTFan/EVENT"; 99 | settings.MQTT_MED_Payload = "s3"; 100 | settings.MQTT_HIGH_Topic = "cmnd/HTFan/EVENT"; 101 | settings.MQTT_HIGH_Payload = "s4"; 102 | settings.GlobalOffsetMS = 2000; 103 | settings.ECOSpinupOffsetMS = 1400; 104 | settings.LOWSpinupOffsetMS = 1200; 105 | settings.MEDSpinupOffsetMS = 1000; 106 | settings.HIGHSpinupOffsetMS = 800; 107 | settings.SpindownOffsetMS = 250; 108 | } 109 | 110 | return settings; 111 | } 112 | 113 | public static string SaveSettings(Settings settings) 114 | { 115 | string error = null; 116 | try 117 | { 118 | JsonSerializerOptions options = new JsonSerializerOptions(); 119 | options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault; 120 | options.WriteIndented = true; 121 | 122 | string jsonSettings = JsonSerializer.Serialize(settings, options); 123 | 124 | File.WriteAllText(Path.Combine(ConfigHelper._rootPath, "HTFanControlSettings.json"), jsonSettings); 125 | } 126 | catch(Exception e) 127 | { 128 | error = e.Message; 129 | } 130 | 131 | return error; 132 | } 133 | } 134 | } -------------------------------------------------------------------------------- /HTFanControl/Util/WinRegistry.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Win32; 2 | 3 | namespace HTFanControl.Util 4 | { 5 | class WinRegistry 6 | { 7 | //stops MS Edge from blocking local IPs that failed to load 8 | public static void FixMSEdge() 9 | { 10 | try 11 | { 12 | RegistryKey key = Registry.CurrentUser.OpenSubKey("Software\\Classes\\Local Settings\\Software\\Microsoft\\Windows\\CurrentVersion\\AppContainer\\Storage\\microsoft.microsoftedge_8wekyb3d8bbwe\\MicrosoftEdge\\TabProcConfig", true); 13 | if (key != null) 14 | { 15 | string[] values = key.GetValueNames(); 16 | 17 | foreach (string value in values) 18 | { 19 | key.DeleteValue(value, false); 20 | } 21 | } 22 | } 23 | catch { } 24 | } 25 | 26 | //if MPC-HC is running on the same system as HTFanControl, this tries to set MPC-HC timer interval to the quickest interval which makes HTFanControl more accurate 27 | public static void SetMPCHCTimerInterval() 28 | { 29 | try 30 | { 31 | RegistryKey key = Registry.CurrentUser.OpenSubKey("Software\\MPC-HC\\MPC-HC\\Settings", true); 32 | 33 | if (key != null) 34 | { 35 | object value = key.GetValue("TimeRefreshInterval"); 36 | 37 | if (value != null) 38 | { 39 | key.SetValue("TimeRefreshInterval", 40); 40 | } 41 | } 42 | } 43 | catch { } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /HTFanControl/htfancontrol.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nicko88/HTFanControl/25954d8da52ba5fb215527427987ee9df501009a/HTFanControl/htfancontrol.ico -------------------------------------------------------------------------------- /HTFanControl/html/add.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Add Wind Track 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 32 | 77 | 78 | 79 |
80 | 81 | 95 | 96 | Upload a wind track file to your local storage. 97 |

98 | 99 |

100 | 101 | 102 |
103 | 104 | -------------------------------------------------------------------------------- /HTFanControl/html/checkupdate.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Check Update 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 44 | 94 | 95 | 96 |
97 | 98 | 109 | 110 | {body} 111 | 112 |
113 | 114 | -------------------------------------------------------------------------------- /HTFanControl/html/crashlogs.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Crashlogs 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 37 | 82 | 83 | 84 |
85 | 86 | 100 | 101 | Click an item below to view the crashlog. 102 | 103 | {body} 104 | 105 |
106 | 107 | 108 |
109 | 110 |
111 | 112 |
113 | 114 | -------------------------------------------------------------------------------- /HTFanControl/html/download.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Save Wind Track 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 46 | 91 | 92 | 93 |
94 | 95 | 109 | 110 |
111 |
112 | 113 |

114 |
115 | {windtrack} 116 |
117 | 118 | 119 | 120 |
121 | 122 | -------------------------------------------------------------------------------- /HTFanControl/html/downloadlist.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Online Wind Track Database 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 44 | 89 | 90 | 91 |
92 | 93 | 107 | 108 |
109 |
110 |
Click on a title below to download wind track.
111 | 112 | 116 | 117 | 118 | 119 |
{body}
120 | 121 |
122 | 123 | 124 |
125 | 126 |
127 | 128 | -------------------------------------------------------------------------------- /HTFanControl/html/edit.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Edit Wind Track 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 62 | 107 | 108 | 109 |
110 | 111 | 125 | 126 |
127 |
128 | Click rename to assign this wind track to the currently playing video. 129 |

130 | 131 | 132 |

133 | 134 |
135 | {windtrack} 136 |
137 | 138 | 139 | 140 |
141 | 142 | -------------------------------------------------------------------------------- /HTFanControl/html/fantester.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Fan Tester 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 32 | 82 | 83 | 84 |
85 | 86 | 100 | 101 |

102 | 103 |

104 | 105 |

106 | 107 |

108 | 109 |

110 | 111 | 112 |
113 | 114 | -------------------------------------------------------------------------------- /HTFanControl/html/loadedwindtrack.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Loaded Wind Track 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 30 | 80 | 81 | 82 |
83 | 84 | 92 | 93 |
94 | 95 |
96 | 97 | -------------------------------------------------------------------------------- /HTFanControl/html/logviewer.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Log Viewer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 30 | 75 | 76 | 77 |
78 | 79 | 93 | 94 |
95 | 96 |
97 | 98 | -------------------------------------------------------------------------------- /HTFanControl/html/manage.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Manage Wind Tracks 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 35 | 80 | 81 | 82 |
83 | 84 | 98 | 99 |
100 |
101 | Click on a video below to view/edit wind track. 102 | 103 | {body} 104 | 105 |
106 | 107 |
108 | 109 |
110 | 111 | -------------------------------------------------------------------------------- /HTFanControl/html/selectaudiodevice.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Select Audio Device 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 43 | 88 | 89 | 90 |
91 | 92 | 103 | 104 |

Select Your Audio Input Device

105 |
106 |
107 | 108 |
109 | 110 | -------------------------------------------------------------------------------- /HTFanControl/html/selectplexplayer.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Select Plex Client 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 50 | 95 | 96 | 97 |
98 | 99 | 110 | 111 |

Select Your Plex Player

112 | Open the Plex App on your playback device to see and select it from the list below. 113 |

114 |
115 | 116 |
117 | 118 | -------------------------------------------------------------------------------- /HTFanControl/html/selectvideo.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Select Video 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 42 | 87 | 88 | 89 |
90 | 91 | 102 | 103 |
104 |
105 | Click on a video below to select it. 106 | 107 | {body} 108 | 109 |
110 | 111 | -------------------------------------------------------------------------------- /HTFanControl/html/settings.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | General Settings 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 244 | 308 | 309 | 310 |
311 | 312 | 326 | 327 |

Settings

328 |
329 | 330 |
331 |
332 | Media Player Type 333 |
334 |
335 | 336 | 337 |
338 |
339 | 340 | 341 |
342 |
343 | 344 | 345 |
346 |
347 | 348 | 349 |
350 |
351 | 352 | 353 |
354 |
355 | 356 | 357 |
358 |
359 |
360 | 361 |
362 |
363 |
{lblPlayer}
364 |
Port
365 |
366 |
367 | 368 | 379 | 380 | 385 | 386 |
387 |
388 | Fan Control Type 389 |
390 |
391 | 392 | 393 |
394 |
395 | 396 | 397 |
398 |
399 | 400 | 401 |
402 |
403 |
404 |
405 |
406 |
LIRC IP
407 |
Port
408 |
409 |
410 |
Remote Name
411 |
ON Delay (ms)
412 |
413 |
414 | 415 | 426 | 427 | 466 | 467 | 529 | 530 | Fan Offset Calibration 531 |
532 |
Global Offset (ms)
533 |
SpinDown (ms)
534 |
535 |
536 |
ECO SpinUp (ms)
537 |
LOW SpinUp (ms)
538 |
539 |
540 |
MED SpinUp (ms)
541 |
HIGH SpinUp (ms)
542 |
543 | 544 |
545 | 546 |
547 |
548 |
549 | 550 |
551 | {version} 552 |

553 |

554 | 555 |
556 | 557 | -------------------------------------------------------------------------------- /HTFanControl/html/status.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | HTFanControl {version} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 56 | 112 | 113 | 114 |
115 | 116 | 127 | 128 |
129 | 130 | 131 | 132 | 133 |
134 | 135 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ## [Download latest release](https://github.com/nicko88/HTFanControl/releases/latest) 2 | 3 | # HTFanControl 4 | #### 4D Theater Wind Effect - DIY Home Theater Project 5 | 6 | HTFanControl is an application meant to control fans in your home theater in order to create a wind effect during movies. 7 | 8 | The program is meant to run in the background and be controlled through a web interface, typically from your smartphone. 9 | 10 | ### User Demo Video 11 | 12 | [![User Demo Video](https://img.youtube.com/vi/iROCqS2yFdc/0.jpg)](https://www.youtube.com/watch?v=iROCqS2yFdc) 13 | 14 | ### Getting Started 15 | 16 | There is a great project guide on the wiki [here](https://github.com/nicko88/HTFanControl/wiki/4D-Wind-Project-Guide-2021). 17 | 18 | Otherwise come join the community forum thread to ask questions [here](https://www.avsforum.com/forum/28-tweaks-do-yourself/3152346-4d-theater-wind-effect-diy-home-theater-project.html). 19 | 20 | You can find help from me (user: [SirMaster](https://www.avsforum.com/forum/members/8147918-sirmaster.html)) or other users of HTFanControl there. 21 | 22 | ### Raspberry Pi / Linux Installation 23 | This install script is intended to install HTFanControl on RasPi or standard Linux running a Debian-based distribution using systemd. It may work on other distributions but it has not been tested. You can also download the Linux release and install it manually onto your particular Linux machine. 24 | 25 | This script will ask to install HTFanControl and also additionally mosquitto MQTT broker which is needed to control the fan relay switch over the network. 26 | #### Install 27 | sudo wget https://raw.githubusercontent.com/nicko88/HTFanControl/master/install/install.sh && sudo bash install.sh 28 | #### Update 29 | There is an update function built into the app at the bottom of the Settings screen, or you can run the update script manually here: 30 | 31 | sudo wget https://raw.githubusercontent.com/nicko88/HTFanControl/master/install/update.sh && sudo bash update.sh 32 | #### Uninstall 33 | sudo wget https://raw.githubusercontent.com/nicko88/HTFanControl/master/install/uninstall.sh && sudo bash uninstall.sh 34 | 35 | ### Wind Tracks 36 | 37 | HTFanControl uses specially created wind track files for each movie with coded time stamps and wind speeds. 38 | 39 | A current database of wind track files created by the community is hosted [here](https://drive.google.com/drive/u/0/folders/13xoJMKeXX69woyt1Qzd_Qz_L6MUwTd1K). 40 | 41 | These wind tracks can also be downloaded through the HTFanControl web interface as well. 42 | 43 | #### Creating Wind Tracks 44 | 45 | A companion app called WindTrackCreator has been created to help the process of making wind tracks for your movies. 46 | 47 | You can find the WindTrackCreator project [here](https://github.com/nicko88/WindTrackCreator). -------------------------------------------------------------------------------- /install/HTFanControl.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=HTFanControl 3 | After=multi-user.target 4 | 5 | [Service] 6 | ExecStart=/opt/HTFanControl/HTFanControl 7 | 8 | [Install] 9 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /install/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $(getconf LONG_BIT) =~ "32" ]] 4 | then 5 | file="HTFanControl_RasPi.zip" 6 | elif [[ $(uname -m) =~ "aarch" ]] 7 | then 8 | file="HTFanControl_RasPi64.zip" 9 | else 10 | file="HTFanControl_Linux.zip" 11 | fi 12 | 13 | read -p "Are you sure you want to INSTALL HTFanControl? [y/n]" -n 1 -r 14 | echo 15 | if [[ $REPLY =~ ^[Yy]$ ]] 16 | then 17 | mkdir /opt/HTFanControl 18 | wget -O /tmp/$file https://github.com/nicko88/HTFanControl/releases/latest/download/$file 19 | unzip /tmp/$file -d /opt/HTFanControl 20 | chmod +x /opt/HTFanControl/HTFanControl 21 | wget -O /lib/systemd/system/HTFanControl.service https://raw.githubusercontent.com/nicko88/HTFanControl/master/install/HTFanControl.service 22 | systemctl daemon-reload 23 | systemctl enable HTFanControl.service 24 | service HTFanControl start 25 | rm /tmp/$file 26 | fi 27 | 28 | read -p "Do you want to INSTALL mosquitto MQTT broker? [y/n]" -n 1 -r 29 | echo 30 | if [[ $REPLY =~ ^[Yy]$ ]] 31 | then 32 | apt-get update 33 | apt-get install -y mosquitto 34 | systemctl daemon-reload 35 | systemctl enable mosquitto.service 36 | echo "allow_anonymous true" >> /etc/mosquitto/mosquitto.conf 37 | echo "listener 1883" >> /etc/mosquitto/mosquitto.conf 38 | service mosquitto restart 39 | fi 40 | 41 | rm install.sh -------------------------------------------------------------------------------- /install/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | read -p "Are you sure you want to UNINSTALL HTFanControl? [y/n]" -n 1 -r 4 | echo 5 | if [[ $REPLY =~ ^[Yy]$ ]] 6 | then 7 | service HTFanControl stop 8 | systemctl disable HTFanControl.service 9 | rm /lib/systemd/system/HTFanControl.service 10 | systemctl daemon-reload 11 | rm -rf /opt/HTFanControl 12 | fi 13 | 14 | read -p "Do you want to UNINSTALL mosquitto MQTT broker? [y/n]" -n 1 -r 15 | echo 16 | if [[ $REPLY =~ ^[Yy]$ ]] 17 | then 18 | service mosquitto stop 19 | systemctl disable mosquitto.service 20 | rm /lib/systemd/system/mosquitto.service 21 | systemctl daemon-reload 22 | apt-get autoremove mosquitto --purge -y 23 | fi 24 | 25 | rm uninstall.sh -------------------------------------------------------------------------------- /install/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $(getconf LONG_BIT) =~ "32" ]] 4 | then 5 | file="HTFanControl_RasPi.zip" 6 | elif [[ $(uname -m) =~ "aarch" ]] 7 | then 8 | file="HTFanControl_RasPi64.zip" 9 | else 10 | file="HTFanControl_Linux.zip" 11 | fi 12 | 13 | wget -O /tmp/$file https://github.com/nicko88/HTFanControl/releases/latest/download/$file 14 | rm /opt/HTFanControl/HTFanControl 15 | unzip /tmp/$file -d /opt/HTFanControl 16 | chmod +x /opt/HTFanControl/HTFanControl 17 | rm /tmp/$file 18 | rm /opt/HTFanControl/update.sh 19 | rm update.sh 20 | service HTFanControl restart --------------------------------------------------------------------------------