79 Commits

Author SHA1 Message Date
0384879bea Handle multiple countries for national days 2026-04-01 12:16:17 -04:00
c088ab8292 Add another national day source since DOTY is broken 2026-04-01 11:58:51 -04:00
8de424087f Fix parsing of boolean (false) excerpts 2026-02-28 10:47:39 -05:00
ad70456a66 Color weather and power values if more than 5 seconds old 2026-02-11 21:31:53 +00:00
2f25286c21 Add alerts when devices reconnect after stopping 2025-10-20 19:08:21 -04:00
98fa161eb1 Fix secret 2025-10-20 13:02:18 -04:00
d8e760931e Update Telegram channel for connectivity alerts 2025-10-15 21:03:02 -04:00
e00bb35589 Remove heat index upper bound 2025-06-24 22:46:36 +00:00
2f47d4c426 Add start time to calendar entries 2025-04-17 12:49:59 +00:00
28c82dc2c3 Update national days to full height until there's more content 2025-03-12 23:15:00 +00:00
601b6d8299 Add latest tag 2025-02-10 22:43:07 +00:00
d8096e92f0 Try to fix tags on build (push seems okay) 2025-02-10 22:35:59 +00:00
4bf3fe204c Try to fix tags 2025-02-10 22:32:11 +00:00
f007764d66 Add repository to push 2025-02-10 22:29:06 +00:00
fba31dfc33 Update to latest Docker task 2025-02-10 22:22:47 +00:00
2c30d131c5 Update corepack 2025-02-10 21:59:06 +00:00
6454bba7c8 UI tweaks 2025-02-10 21:44:23 +00:00
766fa7c0cf Update SQL Server version 2025-01-13 21:24:31 +00:00
8a15e06f35 Display improvements 2024-12-27 15:29:07 +00:00
89a428f8e7 Add national days header and fix layout 2024-12-23 17:43:53 +00:00
86c39c252f Add stop for opening/closing of garage 2024-12-23 15:55:33 +00:00
5eb7d4a666 Fix national days with HTML encoded characters 2024-12-22 20:43:04 +00:00
d9a25a9832 Pass time zone to national days API 2024-12-22 03:44:10 +00:00
b27ccc1f49 Add timezone to national days API 2024-12-21 22:09:32 -05:00
6c00cf12e1 Add label for temperature mode 2024-12-22 00:16:36 +00:00
04a1bacb2b Add temperature "feels like" toggle 2024-12-21 23:56:21 +00:00
64f794b1b0 Add excerpt display for national days 2024-12-21 23:26:28 +00:00
d069167f59 Stop being lazy and use proper "next day" timer logic 2024-12-20 19:13:15 +00:00
c2f112dfc9 Add wind and pressure to kiosk 2024-12-20 17:59:39 +00:00
ba18ba2562 Put back real event now that touch works better 2024-12-20 17:19:53 +00:00
7431cd2233 Adjustments for touch 2024-12-20 17:11:58 +00:00
cb00c88197 Temporary alert for touch testing 2024-12-20 17:02:40 +00:00
29a58941ad Capture pointer during long press 2024-12-20 16:54:33 +00:00
79a8f837df Switch to pointer events 2024-12-20 16:42:52 +00:00
2b52a15f96 Add long press button for actions 2024-12-20 16:36:29 +00:00
215be2d5f0 Split calendar and national days to components 2024-12-19 21:31:47 +00:00
28ed529cc0 Smarter refreshing of national days 2024-12-19 19:01:38 +00:00
5419f50d8b Do national days as a sorted list 2024-12-19 18:50:00 +00:00
327bb6f7b9 Sort calendar entries by holiday then name 2024-12-19 18:46:28 +00:00
8f8f4179f9 Add national days 2024-12-19 16:42:22 +00:00
edc4d13d85 Add national days API to calendar 2024-12-19 11:03:40 -05:00
532ee37169 Color holidays 2024-12-19 15:08:01 +00:00
2c740dd604 Merge branch 'master' of github.com:ckaczor/HomeMonitor 2024-12-19 09:51:48 -05:00
88cf5d1b83 Add holiday flag to data 2024-12-19 09:51:38 -05:00
28645d8bc3 Add holidays to calendar 2024-12-19 14:25:45 +00:00
200a07d6ba Add optional holidays to upcoming 2024-12-19 09:21:03 -05:00
5e216867c1 Change icon coloring 2024-12-19 14:07:28 +00:00
87969ffe60 Remove month from calendar list 2024-12-18 22:16:33 +00:00
da7c19714f Add ping and ability to control garage 2024-12-18 22:11:51 +00:00
26aadfb65f Update calendar CORS 2024-12-18 15:51:54 -05:00
1ff4e1580e Kiosk improvements 2024-12-18 20:38:41 +00:00
9ccc4ec9de Add back missing type to make hardware happy for now 2024-12-18 12:53:51 -05:00
dc72f71e91 Calendar updates 2024-12-18 17:24:41 +00:00
40ebbf38cb Replace calendar service with new .NET version 2024-12-18 11:59:05 -05:00
4dbedee94b Fix token 2024-12-17 19:49:23 +00:00
031949409b Embed calendar and change colors 2024-12-17 19:42:32 +00:00
5ea5019756 Increase text size 2024-12-17 14:57:01 +00:00
7edb527957 Left hardcoded test version 2024-12-17 14:31:43 +00:00
60563bc20b Tweak nginx config/start 2024-12-17 14:23:47 +00:00
bae01ed569 Fix artifact location 2024-12-17 14:22:31 +00:00
e7eadf5167 Fix container start 2024-12-17 14:21:39 +00:00
e13839dfc3 Fix dumb copy/paste error and standardize token format 2024-12-17 13:38:52 +00:00
dd67a7fa1c Fix copy of entrypoint script 2024-12-17 13:38:21 +00:00
ba1ab27fb0 Fix job name 2024-12-17 13:34:20 +00:00
36635224e8 Try setting config for dual deployment 2024-12-17 13:32:41 +00:00
32675c41bc Okay, empty strings then 2024-12-16 21:44:09 +00:00
419349baa4 Add empty config values 2024-12-16 21:41:29 +00:00
7b24f82436 Continue kiosk work 2024-12-16 21:38:24 +00:00
d563e89c6b Start adding kiosk view 2024-12-12 15:22:23 +00:00
6925319487 Update packages 2024-12-08 02:05:23 +00:00
dc9320e12b Update packages/config 2024-12-08 01:49:04 +00:00
d24cb4555f Add package manager setting 2024-12-08 01:01:36 +00:00
b4bd48a889 Fix casing warning 2024-12-08 01:01:15 +00:00
39625a7d82 Tweak navigation 2024-12-08 00:23:28 +00:00
94a818beda Update interval 2024-05-28 18:30:21 -04:00
0e651143c1 Have both bot token secrets 2024-05-28 17:20:42 -04:00
3d7fa1afa6 Add Telegram secrets 2024-05-28 16:49:32 -04:00
4917493f21 Add timer to send alerts 2024-05-28 16:44:57 -04:00
6bf61966b8 Update weather pod assignment 2024-05-28 15:45:50 -04:00
92 changed files with 11763 additions and 14055 deletions

25
Calendar/Calendar.sln Normal file
View File

@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.8.34330.188
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Service", "Service\Service.csproj", "{AAF49637-5EAB-4D0F-A3EB-4421456DF709}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{AAF49637-5EAB-4D0F-A3EB-4421456DF709}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AAF49637-5EAB-4D0F-A3EB-4421456DF709}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AAF49637-5EAB-4D0F-A3EB-4421456DF709}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AAF49637-5EAB-4D0F-A3EB-4421456DF709}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AF8B4FE8-1A8C-4189-8A16-BFD216D13047}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,8 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_ACCESSOR_ATTRIBUTE_ON_SAME_LINE_EX/@EntryValue">NEVER</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_ACCESSORHOLDER_ATTRIBUTE_ON_SAME_LINE_EX/@EntryValue">NEVER</s:String>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpKeepExistingMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Kaczor/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -1 +0,0 @@
PORT=8080

View File

@@ -1,36 +0,0 @@
{
"env": {
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"windows"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
}
}

View File

@@ -1,2 +1,484 @@
dist/
node_modules/
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

View File

@@ -1,5 +0,0 @@
{
"tabWidth": 4,
"useTabs": false,
"singleQuote": true
}

View File

@@ -0,0 +1,51 @@
@Calendar_HostAddress = http://localhost:5060
GET {{Calendar_HostAddress}}/calendar/upcoming
Accept: application/json
###
GET {{Calendar_HostAddress}}/calendar/upcoming?days=7
Accept: application/json
###
GET {{Calendar_HostAddress}}/calendar/upcoming?includeHolidays=true
Accept: application/json
###
GET {{Calendar_HostAddress}}/calendar/upcoming?days=7&includeHolidays=true
Accept: application/json
###
GET {{Calendar_HostAddress}}/events/next
Accept: application/json
###
GET {{Calendar_HostAddress}}/events/next?timezone=America/New_York
Accept: application/json
###
GET {{Calendar_HostAddress}}/national-days/today
Accept: application/json
###
GET {{Calendar_HostAddress}}/national-days/today?timezone=America/New_York
Accept: application/json
###
GET {{Calendar_HostAddress}}/national-days/today?timezone=America/New_York&provider=DaysOfTheYear
Accept: application/json
###
GET {{Calendar_HostAddress}}/national-days/today?timezone=America/New_York&provider=HolidayCalendar
Accept: application/json
###

View File

@@ -0,0 +1,40 @@
using ChrisKaczor.HomeMonitor.Calendar.Service.Models;
using Microsoft.AspNetCore.Mvc;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Controllers;
[Route("calendar")]
[ApiController]
public class CalendarController(IConfiguration configuration, HttpClient httpClient) : ControllerBase
{
private async Task<IEnumerable<CalendarEntry>> GetCalendarEntries(string calendarUrl, int days, bool isHoliday)
{
var data = await httpClient.GetStringAsync(calendarUrl);
var calendar = Ical.Net.Calendar.Load(data);
var start = DateTimeOffset.Now.Date;
var end = start.AddDays(days);
var calendarEntries = calendar
.GetOccurrences(start, end)
.Select(o => new CalendarEntry(o, isHoliday))
.OrderBy(ce => ce.Start);
return calendarEntries;
}
[HttpGet("upcoming")]
public async Task<ActionResult<IEnumerable<CalendarEntry>>> GetUpcoming([FromQuery] int days = 1, [FromQuery] bool includeHolidays = false)
{
var calendarEntries = await GetCalendarEntries(configuration["Calendar:PersonalUrl"]!, days, false);
if (!includeHolidays)
return Ok(calendarEntries);
var holidayEntries = await GetCalendarEntries(configuration["Calendar:HolidayUrl"]!, days, true);
calendarEntries = calendarEntries.Concat(holidayEntries).OrderBy(c => c.Start);
return Ok(calendarEntries);
}
}

View File

@@ -0,0 +1,35 @@
using ChrisKaczor.HomeMonitor.Calendar.Service.Models;
using Microsoft.AspNetCore.Mvc;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Controllers;
[Route("events")]
[ApiController]
public class HolidayController(IConfiguration configuration, HttpClient httpClient) : ControllerBase
{
[HttpGet("next")]
public async Task<ActionResult<IEnumerable<CalendarEntry>>> GetNext([FromQuery] string timezone = "Etc/UTC")
{
var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
var data = await httpClient.GetStringAsync(configuration["Calendar:HolidayUrl"]);
var calendar = Ical.Net.Calendar.Load(data);
var start = DateTimeOffset.Now.Date;
var end = start.AddYears(1);
var calendarEntries = calendar
.GetOccurrences(start, end)
.Select(o => new CalendarEntry(o, true))
.OrderBy(ce => ce.Start);
var nextCalendarEntry = calendarEntries.First();
var holidayEntry = new HolidayEntry(nextCalendarEntry, timeZoneInfo);
var holidayResponse = new HolidayResponse(holidayEntry, timeZoneInfo);
return Ok(holidayResponse);
}
}

View File

@@ -0,0 +1,69 @@
using ChrisKaczor.HomeMonitor.Calendar.Service.Models;
using Microsoft.AspNetCore.Mvc;
using RestSharp;
using DaysOfTheYear = ChrisKaczor.HomeMonitor.Calendar.Service.Models.DaysOfTheYear;
using HolidayCalendar = ChrisKaczor.HomeMonitor.Calendar.Service.Models.HolidayCalendar;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Controllers;
[Route("national-days")]
[ApiController]
public class NationalDaysController(IConfiguration configuration, RestClient restClient) : ControllerBase
{
[HttpGet("today")]
public async Task<ActionResult<IEnumerable<NationalDay>>> GetToday([FromQuery] string timezone = "Etc/UTC", [FromQuery] string provider = "")
{
if (string.IsNullOrEmpty(provider))
{
provider = configuration["Calendar:NationalDays:Provider"] ?? string.Empty;
}
var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone);
var timeZoneOffset = timeZoneInfo.GetUtcOffset(DateTimeOffset.Now);
if (provider == "DaysOfTheYear")
{
return Ok(await GetFromDaysOfTheYear(timeZoneOffset));
}
return Ok(await GetFromHolidayCalendar(timeZoneOffset));
}
private async Task<IEnumerable<NationalDay>?> GetFromDaysOfTheYear(TimeSpan timeZoneOffset)
{
var timeZoneOffsetHours = timeZoneOffset.TotalHours;
var restRequest = new RestRequest(configuration["Calendar:DaysOfTheYear:Url"]);
restRequest.AddHeader("X-Api-Key", configuration["Calendar:DaysOfTheYear:Key"] ?? string.Empty);
restRequest.AddQueryParameter("timezone_offset", timeZoneOffsetHours);
var response = await restClient.GetAsync<DaysOfTheYear.Response>(restRequest);
var items = response?.Data.Where(d => d.Type == "day");
var nationalDays = items?.Select(i => new NationalDay(i));
return nationalDays;
}
private async Task<IEnumerable<NationalDay>?> GetFromHolidayCalendar(TimeSpan timeZoneOffset)
{
var now = DateTimeOffset.UtcNow.ToOffset(timeZoneOffset);
var dateString = now.ToString("yyyy-MM-dd");
var countries = configuration.GetSection("Calendar:HolidayCalendar:CountryCodes").Get<List<string>>() ?? [];
var restRequest = new RestRequest(configuration["Calendar:HolidayCalendar:Url"]);
restRequest.AddHeader("Authorization", $"Bearer {configuration["Calendar:HolidayCalendar:Key"] ?? string.Empty}");
restRequest.AddQueryParameter("limit", 100);
restRequest.AddQueryParameter("type", "Day");
restRequest.AddQueryParameter("startDate", dateString);
restRequest.AddQueryParameter("endDate", dateString);
var response = await restClient.GetAsync<HolidayCalendar.Response>(restRequest);
var nationalDays = response?.Data.Items.Where(i => countries.Contains(i.Country.Code)).Select(i => new NationalDay(i));
return nationalDays;
}
}

View File

@@ -1,15 +1,18 @@
FROM node:20-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS base
WORKDIR /app
EXPOSE 8080
RUN npm run build
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /src
COPY ["./Service.csproj", "./"]
RUN dotnet restore "Service.csproj"
COPY . .
WORKDIR "/src"
RUN dotnet publish "Service.csproj" -c Release -o /app
CMD ["npm", "run", "dist"]
FROM base AS final
WORKDIR /app
COPY --from=build /app .
RUN apk add --no-cache tzdata icu-libs
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENTRYPOINT ["dotnet", "ChrisKaczor.HomeMonitor.Calendar.Service.dll"]

View File

@@ -0,0 +1,15 @@
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using JetBrains.Annotations;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models;
[PublicAPI]
public class CalendarEntry(Occurrence occurrence, bool isHoliday)
{
public string Summary { get; set; } = ((CalendarEvent)occurrence.Source).Summary;
public bool IsAllDay { get; set; } = ((CalendarEvent)occurrence.Source).IsAllDay;
public DateTimeOffset Start { get; set; } = occurrence.Period.StartTime.AsDateTimeOffset;
public DateTimeOffset End { get; set; } = occurrence.Period.EndTime.AsDateTimeOffset;
public bool IsHoliday { get; set; } = isHoliday;
}

View File

@@ -0,0 +1,17 @@
using JetBrains.Annotations;
using System.Text.Json.Serialization;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models.DaysOfTheYear;
[PublicAPI]
public class Entry
{
public string Name { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
[JsonConverter(typeof(StringOrBooleanConverter))]
public string Excerpt { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,17 @@
using JetBrains.Annotations;
using System.Text.Json.Serialization;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models.DaysOfTheYear;
[PublicAPI]
public class Meta
{
[JsonPropertyName("cache_status")]
public string CacheStatus { get; set; } = string.Empty;
[JsonPropertyName("response_count")]
public int ResponseCount { get; set; }
[JsonPropertyName("request_type")]
public string RequestType { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,11 @@
using JetBrains.Annotations;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models.DaysOfTheYear;
[PublicAPI]
public class Response
{
public int Code { get; set; }
public Meta Meta { get; set; } = new();
public IEnumerable<Entry> Data { get; set; } = [];
}

View File

@@ -0,0 +1,19 @@
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models;
public class Duration
{
public int Days { get; set; }
public int Hours { get; set; }
public int Minutes { get; set; }
public int Seconds { get; set; }
public Duration(DateTimeOffset date)
{
var now = DateTimeOffset.Now;
var duration = date - now;
Days = duration.Days;
Hours = duration.Hours;
Minutes = duration.Minutes;
Seconds = duration.Seconds;
}
}

View File

@@ -0,0 +1,11 @@
using JetBrains.Annotations;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models.HolidayCalendar;
[PublicAPI]
public class Country
{
public required string Id { get; set; }
public required string Name { get; set; }
public required string Code { get; set; }
}

View File

@@ -0,0 +1,13 @@
using JetBrains.Annotations;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models.HolidayCalendar;
[PublicAPI]
public class Data
{
public IEnumerable<Item> Items { get; set; } = [];
public int Total { get; set; }
public int Page { get; set; }
public int Limit { get; set; }
public int TotalPages { get; set; }
}

View File

@@ -0,0 +1,14 @@
using JetBrains.Annotations;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models.HolidayCalendar;
[PublicAPI]
public class Item
{
public required string Id { get; set; }
public required string Name { get; set; }
public required string Excerpt { get; set; }
public required string Url { get; set; }
public required string Type { get; set; }
public required Country Country { get; set; }
}

View File

@@ -0,0 +1,10 @@
using JetBrains.Annotations;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models.HolidayCalendar;
[PublicAPI]
public class Response
{
public bool Success { get; set; }
public Data Data { get; set; } = new();
}

View File

@@ -0,0 +1,19 @@
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models;
public class HolidayEntry
{
public string Name { get; set; }
public DateTimeOffset Date { get; set; }
public string Type { get; set; }
public bool IsToday { get; set; }
public Duration DurationUntil { get; set; }
public HolidayEntry(CalendarEntry calendarEntry, TimeZoneInfo timeZoneInfo)
{
Name = calendarEntry.Summary;
Date = TimeZoneInfo.ConvertTime(calendarEntry.Start, timeZoneInfo).Subtract(timeZoneInfo.GetUtcOffset(calendarEntry.Start));
Type = "public";
IsToday = Date.Date == DateTimeOffset.UtcNow.Date;
DurationUntil = new Duration(Date);
}
}

View File

@@ -0,0 +1,10 @@
using JetBrains.Annotations;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models;
[PublicAPI]
public class HolidayResponse(HolidayEntry holidayEntry, TimeZoneInfo timeZoneInfo)
{
public DateTimeOffset ResponseTime { get; set; } = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZoneInfo);
public HolidayEntry? Event { get; set; } = holidayEntry;
}

View File

@@ -0,0 +1,31 @@
using JetBrains.Annotations;
using System.Diagnostics.CodeAnalysis;
namespace ChrisKaczor.HomeMonitor.Calendar.Service.Models;
[PublicAPI]
public class NationalDay
{
public required string Name { get; set; }
public required string Url { get; set; }
public required string Excerpt { get; set; }
public required string Type { get; set; }
[SetsRequiredMembers]
public NationalDay(HolidayCalendar.Item item)
{
Name = item.Name;
Url = item.Url;
Excerpt = item.Excerpt;
Type = item.Type;
}
[SetsRequiredMembers]
public NationalDay(DaysOfTheYear.Entry entry)
{
Name = entry.Name;
Url = entry.Url;
Excerpt = entry.Excerpt;
Type = entry.Type;
}
}

View File

@@ -0,0 +1,41 @@
using ChrisKaczor.Common.OpenTelemetry;
using RestSharp;
using System.Reflection;
namespace ChrisKaczor.HomeMonitor.Calendar.Service;
public static class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(o => o.AddPolicy("CorsPolicy", corsPolicyBuilder => corsPolicyBuilder
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()
.WithOrigins("http://localhost:4200", "http://172.23.10.3:9001")));
builder.Configuration.AddEnvironmentVariables();
builder.Services.AddCommonOpenTelemetry(Assembly.GetExecutingAssembly().GetName().Name,
builder.Configuration["Telemetry:Endpoint"]);
builder.Services.AddSingleton<HttpClient>();
builder.Services.AddSingleton(new RestClient());
builder.Services.AddControllers();
// -- --
var app = builder.Build();
app.UseCors("CorsPolicy");
app.UseAuthorization();
app.MapControllers();
app.Run();
}
}

View File

@@ -0,0 +1,11 @@
{
"profiles": {
"Calendar": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:5060"
}
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>false</InvariantGlobalization>
<RootNamespace>ChrisKaczor.HomeMonitor.Calendar.Service</RootNamespace>
<AssemblyName>ChrisKaczor.HomeMonitor.Calendar.Service</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ChrisKaczor.Common.OpenTelemetry" Version="1.0.2" />
<PackageReference Include="Ical.Net" Version="4.3.1" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0">
<TreatAsUsed>true</TreatAsUsed>
</PackageReference>
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.8.1" />
<PackageReference Include="RestSharp" Version="112.1.0" />
<PackageReference Include="System.Formats.Asn1" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Properties\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,30 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ChrisKaczor.HomeMonitor.Calendar.Service;
public class StringOrBooleanConverter : JsonConverter<string>
{
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.True => string.Empty,
JsonTokenType.False => string.Empty,
JsonTokenType.String => reader.GetString() ?? string.Empty,
_ => throw new JsonException($"Unexpected token type: {reader.TokenType}")
};
}
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
{
if (bool.TryParse(value, out var boolValue))
{
writer.WriteBooleanValue(boolValue);
}
else
{
writer.WriteStringValue(value);
}
}
}

View File

@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Calendar": {
"PersonalUrl": "",
"HolidayUrl": "",
"NationalDays": {
"Url": "",
"Key": ""
}
}
}

View File

@@ -0,0 +1,28 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft": "Information"
}
},
"AllowedHosts": "*",
"Telemetry": {
"Endpoint": "http://signoz-otel-collector.platform:4317/"
},
"Calendar": {
"PersonalUrl": "",
"HolidayUrl": "",
"DaysOfTheYear": {
"Url": "",
"Key": ""
},
"HolidayCalendar": {
"Url": "",
"Key": "",
"CountryCodes": [ "US", "INTL" ]
},
"NationalDays": {
"Provider": "HolidayCalendar"
}
}
}

View File

@@ -24,6 +24,37 @@ spec:
imagePullPolicy: Always
securityContext:
privileged: true
env:
- name: Calendar__PersonalUrl
valueFrom:
secretKeyRef:
name: calendar-config
key: PERSONAL_URL
- name: Calendar__HolidayUrl
valueFrom:
secretKeyRef:
name: calendar-config
key: HOLIDAYS_URL
- name: Calendar__DaysOfTheYear__Url
valueFrom:
secretKeyRef:
name: calendar-config
key: DAYS_OF_THE_YEAR_URL
- name: Calendar__DaysOfTheYear__Key
valueFrom:
secretKeyRef:
name: calendar-config
key: DAYS_OF_THE_YEAR_KEY
- name: Calendar__HolidayCalendar__Url
valueFrom:
secretKeyRef:
name: calendar-config
key: HOLIDAY_CALENDAR_URL
- name: Calendar__HolidayCalendar__Key
valueFrom:
secretKeyRef:
name: calendar-config
key: HOLIDAY_CALENDAR_KEY
restartPolicy: Always
terminationGracePeriodSeconds: 30
dnsPolicy: ClusterFirst

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +0,0 @@
{
"name": "calendar",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "ts-node-dev --pretty --respawn ./src/app.ts",
"build": "tsc -p ./",
"dist": "node ./dist/app.js"
},
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"date-holidays": "^3.23.3",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"helmet": "^7.1.0",
"http-status-codes": "^2.3.0",
"luxon": "^3.4.4",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/luxon": "^3.3.8",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"eslint": "^8.56.0",
"ts-node-dev": "^2.0.0",
"typescript": "^5.3.3"
}
}

View File

@@ -1,27 +0,0 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { eventsRouter } from './events/events.routes';
if (!process.env.PORT) {
console.log('No port value specified');
process.exit(1);
}
process.env.TZ = 'Etc/UTC';
const PORT = parseInt(process.env.PORT as string, 10);
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());
app.use(helmet());
app.use('/events/', eventsRouter);
app.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`);
});

View File

@@ -1,41 +0,0 @@
import { HolidaysTypes } from 'date-holidays';
import { DateTime, Interval } from 'luxon';
export class Event {
name: string;
date: DateTime;
type: HolidaysTypes.HolidayType;
isToday: boolean;
durationUntil: {
days: number;
hours: number;
minutes: number;
seconds: number;
} | null;
constructor(holiday: HolidaysTypes.Holiday, timezone: string) {
const now = DateTime.now().setZone(timezone);
this.name = holiday.name;
this.date = DateTime.fromFormat(holiday.date, 'yyyy-MM-dd HH:mm:ss', {
zone: timezone,
}).startOf('day');
this.type = holiday.type;
this.isToday = this.date.hasSame(now, 'day');
const duration = Interval.fromDateTimes(now, this.date).toDuration([
'days',
'hours',
'minutes',
'seconds',
]);
this.durationUntil = !duration.isValid
? null
: {
days: duration.days,
hours: duration.hours,
minutes: duration.minutes,
seconds: Math.round(duration.seconds),
};
}
}

View File

@@ -1,68 +0,0 @@
import express, { Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';
import * as DateHolidays from 'date-holidays';
import { Event } from './event';
import { DateTime } from 'luxon';
export const eventsRouter = express.Router();
function getHolidays(req: Request): DateHolidays.HolidaysTypes.Holiday[] {
const country = req.query.country as string;
const state = req.query.state as string;
const year = parseInt(req.query.year as string, 10);
const timezone = 'Etc/UTC';
const dateHolidays = new DateHolidays.default();
dateHolidays.init(country, state, {
timezone: timezone,
});
const holidays = dateHolidays.getHolidays(year);
return holidays;
}
eventsRouter.get('/next', async (req: Request, res: Response) => {
try {
const timezone = req.query.timezone as string;
const holidays = getHolidays(req);
const events = holidays.map((holiday) => new Event(holiday, timezone));
const now = DateTime.now().setZone(timezone);
const nextEvent = events.find(
(event) => event.date > now || event.isToday
);
if (!nextEvent) {
return res.status(StatusCodes.OK).json(null);
}
return res.status(StatusCodes.OK).json({ responseTime: now, event: nextEvent });
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error });
}
});
eventsRouter.get('/future', async (req: Request, res: Response) => {
try {
const timezone = req.query.timezone as string;
const holidays = getHolidays(req);
const events = holidays.map((holiday) => new Event(holiday, timezone));
const now = DateTime.now().setZone(timezone);
const futureEvents = events.filter(
(event) => event.date > now || event.isToday
);
return res.status(StatusCodes.OK).json({ responseTime: now, events: futureEvents });
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error });
}
});

View File

@@ -1,109 +0,0 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist/", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@@ -31,7 +31,7 @@ spec:
valueFrom:
secretKeyRef:
name: telegram
key: bot-token
key: bot-token-laundry
- name: Telegram__ChatId
valueFrom:
secretKeyRef:

13654
Display/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.1",
"@angular-devkit/build-angular": "^14.1.3",
"@angular/cli": "^14.1.3",
"@angular/compiler-cli": "^14.1.3",
"@angular/language-service": "^14.1.3",

View File

@@ -192,4 +192,6 @@
&lt;/Entry&gt;&#xD;
&lt;/TypePattern&gt;&#xD;
&lt;/Patterns&gt;</s:String>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002EMemberReordering_002EMigrations_002ECSharpFileLayoutPatternRemoveIsAttributeUpgrade/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Kaczor/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mqtt/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -100,12 +100,23 @@ public class Database(IConfiguration configuration)
return await connection.QueryFirstOrDefaultAsync<Device>(query, new { Name = deviceName }).ConfigureAwait(false);
}
public async Task SetDeviceLastUpdatedAsync(string deviceName, DateTimeOffset? lastUpdated)
public async Task<bool> SetDeviceLastUpdatedAsync(string deviceName, DateTimeOffset? lastUpdated)
{
await using var connection = CreateConnection();
var query = ResourceReader.GetString("ChrisKaczor.HomeMonitor.Environment.Service.Data.Queries.SetDeviceLastUpdated.psql");
await connection.ExecuteAsync(query, new { Name = deviceName, LastUpdated = lastUpdated }).ConfigureAwait(false);
var stoppedReporting = await connection.QueryFirstAsync<bool>(query, new { Name = deviceName, LastUpdated = lastUpdated }).ConfigureAwait(false);
return stoppedReporting;
}
public async Task SetDeviceStoppedReportingAsync(string deviceName, bool stoppedReporting)
{
await using var connection = CreateConnection();
var query = ResourceReader.GetString("ChrisKaczor.HomeMonitor.Environment.Service.Data.Queries.SetDeviceStoppedReporting.psql");
await connection.ExecuteAsync(query, new { Name = deviceName, StoppedReporting = stoppedReporting }).ConfigureAwait(false);
}
}

View File

@@ -1,11 +1,16 @@
INSERT INTO device(
name,
last_updated
last_updated,
stopped_reporting
)
VALUES (
@Name,
@LastUpdated
@LastUpdated,
false
)
ON CONFLICT (name)
DO UPDATE
SET last_updated = EXCLUDED.last_updated
SET last_updated = EXCLUDED.last_updated,
stopped_reporting = false
RETURNING
(SELECT stopped_reporting FROM device WHERE name = @Name);

View File

@@ -0,0 +1,6 @@
UPDATE
device
SET
stopped_reporting = @StoppedReporting
WHERE
name = @Name;

View File

@@ -0,0 +1,4 @@
ALTER TABLE
device
ADD COLUMN
stopped_reporting boolean NOT NULL DEFAULT false;

View File

@@ -0,0 +1,70 @@
using ChrisKaczor.HomeMonitor.Environment.Service.Data;
namespace ChrisKaczor.HomeMonitor.Environment.Service;
public class DeviceCheckService(Database database, IConfiguration configuration, TelegramSender telegramSender) : IHostedService
{
private Timer? _timer;
private TimeSpan _warningInterval;
public Task StartAsync(CancellationToken cancellationToken)
{
WriteLog("DeviceCheckService started");
_warningInterval = TimeSpan.Parse(configuration["Environment:DeviceWarningInterval"]!);
var checkInterval = TimeSpan.Parse(configuration["Environment:DeviceCheckInterval"]!);
_timer = new Timer(_ => DoWork().Wait(cancellationToken), null, TimeSpan.Zero, checkInterval);
return Task.CompletedTask;
}
private async Task DoWork()
{
WriteLog("Checking devices started");
var devices = await database.GetDevicesAsync();
foreach (var device in devices)
{
WriteLog($"Checking device: {device.Name}");
var message = string.Empty;
WriteLog($"Device {device.Name} last updated: {(device.LastUpdated == null ? "never" : device.LastUpdated.ToString())}");
if (device.LastUpdated == null)
{
message = $"Device has never reported: {device.Name}";
}
else if (DateTime.UtcNow - device.LastUpdated > _warningInterval)
{
message = $"Device has not reported recently: {device.Name}";
}
if (message.Length > 0)
{
await database.SetDeviceStoppedReportingAsync(device.Name, true);
await telegramSender.SendMessageAsync(message);
}
}
WriteLog("Checking devices finished");
}
public Task StopAsync(CancellationToken cancellationToken)
{
WriteLog("DeviceCheckService stopped");
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
private static void WriteLog(string message)
{
Console.WriteLine(message);
}
}

View File

@@ -17,11 +17,13 @@ public class MessageHandler : IHostedService
private readonly MqttFactory _mqttFactory;
private readonly string _topic;
private readonly HubConnection? _hubConnection;
private readonly TelegramSender _telegramSender;
public MessageHandler(IConfiguration configuration, Database database)
public MessageHandler(IConfiguration configuration, Database database, TelegramSender telegramSender)
{
_configuration = configuration;
_database = database;
_telegramSender = telegramSender;
_topic = _configuration["Mqtt:Topic"] ?? string.Empty;
@@ -77,9 +79,16 @@ public class MessageHandler : IHostedService
await _database.StoreMessageAsync(message);
await _database.SetDeviceLastUpdatedAsync(message.Name, message.Timestamp);
var hadStoppedReporting = await _database.SetDeviceLastUpdatedAsync(message.Name, message.Timestamp);
await SendMessage(message);
if (hadStoppedReporting)
{
var telegramMessage = $"Device now reporting: {message.Name}";
await _telegramSender.SendMessageAsync(telegramMessage);
}
}
private async Task SendMessage(DeviceMessage message)

View File

@@ -19,7 +19,9 @@ public static class Program
builder.Services.AddControllers();
builder.Services.AddTransient<Database>();
builder.Services.AddTransient<TelegramSender>();
builder.Services.AddHostedService<MessageHandler>();
builder.Services.AddHostedService<DeviceCheckService>();
// -- --

View File

@@ -28,8 +28,10 @@
<EmbeddedResource Include="Data\Queries\CreateReading.psql" />
<EmbeddedResource Include="Data\Queries\GetDevices.psql" />
<EmbeddedResource Include="Data\Queries\GetDevice.psql" />
<EmbeddedResource Include="Data\Queries\SetDeviceStoppedReporting.psql" />
<EmbeddedResource Include="Data\Queries\SetDeviceLastUpdated.psql" />
<EmbeddedResource Include="Data\Schema\1-Initial Schema.psql" />
<EmbeddedResource Include="Data\Schema\3-Device Table - StoppedReporting.psql" />
<EmbeddedResource Include="Data\Schema\2-Device Table.psql" />
</ItemGroup>
@@ -40,6 +42,7 @@
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.1" />
<PackageReference Include="MQTTnet.AspNetCore" Version="4.3.3.952" />
<PackageReference Include="RestSharp" Version="112.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,19 @@
using RestSharp;
namespace ChrisKaczor.HomeMonitor.Environment.Service;
public class TelegramSender(IConfiguration configuration)
{
private readonly string _botToken = configuration["Telegram:BotToken"]!;
private readonly string _chatId = configuration["Telegram:PersonalChatId"]!;
private readonly RestClient _restClient = new();
public async Task SendMessageAsync(string message)
{
var encodedMessage = Uri.EscapeDataString(message);
var restRequest = new RestRequest($"https://api.telegram.org/bot{_botToken}/sendMessage?chat_id={_chatId}&text={encodedMessage}");
await _restClient.GetAsync(restRequest);
}
}

View File

@@ -1,25 +1,31 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Mqtt": {
"Server": "172.23.10.3"
},
"Environment": {
"Database": {
"Host": "localhost",
"User": "postgres",
"Password": "postgres",
"Port": 5432,
"Name": "Environment",
"TrustServerCertificate": true
},
"Mqtt": {
"Server": "172.23.10.3"
"Hub": {
"Url": "http://localhost:8080/environment"
},
"Environment": {
"Database": {
"Host": "localhost",
"User": "postgres",
"Password": "postgres",
"Port": 5432,
"Name": "Environment",
"TrustServerCertificate": true
},
"Hub": {
"Url": "http://localhost:8080/environment"
}
},
"AuthorizationToken": "test-token"
"DeviceCheckInterval": "00:00:30",
"DeviceWarningInterval": "00:00:10"
},
"AuthorizationToken": "test-token",
"Telegram": {
"BotToken": "",
"ChatId": ""
}
}

View File

@@ -21,7 +21,9 @@
},
"Hub": {
"Url": "http://hub-server/environment"
}
},
"DeviceCheckInterval": "12:00:00",
"DeviceWarningInterval": "01:00:00"
},
"Telemetry": {
"Endpoint": "http://signoz-otel-collector.platform:4317/"

View File

@@ -117,6 +117,21 @@ spec:
secretKeyRef:
name: authorization
key: token
- name: Telegram__BotToken
valueFrom:
secretKeyRef:
name: telegram
key: bot-token-home
- name: Telegram__GroupChatId
valueFrom:
secretKeyRef:
name: telegram
key: group-chat-id
- name: Telegram__PersonalChatId
valueFrom:
secretKeyRef:
name: telegram
key: personal-chat-id
resources:
limits:
cpu: 1000m

View File

@@ -19,7 +19,7 @@ spec:
spec:
containers:
- name: power-database
image: mcr.microsoft.com/mssql/server
image: mcr.microsoft.com/mssql/server:2022-latest
terminationMessagePath: "/dev/termination-log"
terminationMessagePolicy: File
imagePullPolicy: IfNotPresent

View File

@@ -40,11 +40,7 @@ spec:
restartPolicy: Always
terminationGracePeriodSeconds: 30
dnsPolicy: ClusterFirst
tolerations:
- key: weather
operator: Equal
value: "true"
effect: NoSchedule
nodeName: weather
volumeClaimTemplates:
- metadata:
name: data
@@ -114,8 +110,4 @@ spec:
restartPolicy: Always
terminationGracePeriodSeconds: 30
dnsPolicy: ClusterFirst
tolerations:
- key: "weather"
operator: Equal
value: "true"
effect: NoSchedule
nodeName: weather

View File

@@ -88,7 +88,7 @@ public class WeatherUpdate : WeatherUpdateBase
var temperature = Temperature;
var humidity = Humidity;
if (temperature.IsBetween(80, 100) && humidity.IsBetween(40, 100))
if (temperature > 80 && humidity > 40)
{
HeatIndex = -42.379m + 2.04901523m * temperature + 10.14333127m * humidity -
.22475541m * temperature * humidity - .00683783m * temperature * temperature -

View File

@@ -19,7 +19,7 @@ spec:
spec:
containers:
- name: weather-database
image: mcr.microsoft.com/mssql/server
image: mcr.microsoft.com/mssql/server:2022-latest
terminationMessagePath: "/dev/termination-log"
terminationMessagePolicy: File
imagePullPolicy: IfNotPresent

View File

@@ -1,5 +1,6 @@
# build stage
FROM node:lts-alpine as build-stage
FROM node:lts-alpine AS build-stage
RUN npm install -g corepack@latest
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package*.json ./
@@ -8,9 +9,12 @@ COPY . .
RUN pnpm run build
# production stage
FROM nginx:stable-alpine as production-stage
FROM nginx:stable-alpine AS production-stage
COPY nginx/default.conf /etc/nginx/conf.d/
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
COPY ./nginx/entrypoint.sh ./docker-entrypoint.d/entrypoint.sh
RUN chmod +x ./docker-entrypoint.d/entrypoint.sh
RUN echo "daemon off;" >> /etc/nginx/nginx.conf
CMD ["nginx"]

View File

@@ -8,16 +8,21 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
Almanac: typeof import('./src/components/Almanac.vue')['default']
CalendarAgenda: typeof import('./src/components/CalendarAgenda.vue')['default']
CurrentLaundryStatus: typeof import('./src/components/CurrentLaundryStatus.vue')['default']
CurrentPower: typeof import('./src/components/CurrentPower.vue')['default']
CurrentWeather: typeof import('./src/components/CurrentWeather.vue')['default']
DashboardItem: typeof import('./src/components/DashboardItem.vue')['default']
Indoor: typeof import('./src/components/Indoor.vue')['default']
IndoorSummary: typeof import('./src/components/IndoorSummary.vue')['default']
LongPressButton: typeof import('./src/components/LongPressButton.vue')['default']
NationalDays: typeof import('./src/components/NationalDays.vue')['default']
PressureTrendArrow: typeof import('./src/components/PressureTrendArrow.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TimeRange: typeof import('./src/components/TimeRange.vue')['default']
ValueChart: typeof import('./src/components/ValueChart.vue')['default']
WeatherSummary: typeof import('./src/components/WeatherSummary.vue')['default']
WindDirectionArrow: typeof import('./src/components/WindDirectionArrow.vue')['default']
}
}

View File

@@ -3,66 +3,94 @@ name: $(Rev:r)
pr: none
trigger:
batch: 'true'
branches:
include:
- master
paths:
include:
- WebDisplay
batch: 'true'
branches:
include:
- master
paths:
include:
- WebDisplay
stages:
- stage: Build
jobs:
- job: Build
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Docker@0
displayName: 'Build an image'
inputs:
containerregistrytype: 'Container Registry'
dockerRegistryConnection: 'Docker Hub'
dockerFile: 'WebDisplay/Dockerfile'
imageName: 'ckaczor/home-monitor-web-display:$(Build.BuildNumber)'
includeLatestTag: true
- task: Docker@0
displayName: 'Push an image'
inputs:
containerregistrytype: 'Container Registry'
dockerRegistryConnection: 'Docker Hub'
action: 'Push an image'
imageName: 'ckaczor/home-monitor-web-display:$(Build.BuildNumber)'
includeLatestTag: true
- task: Bash@3
inputs:
targetType: 'inline'
script: 'sed -i s/#BUILD_BUILDNUMBER#/$BUILD_BUILDNUMBER/ WebDisplay/deploy/manifest.yaml'
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: 'WebDisplay/deploy/manifest.yaml'
ArtifactName: 'Manifest'
publishLocation: 'Container'
- stage: Build
jobs:
- job: Build
pool:
vmImage: 'ubuntu-latest'
steps:
- task: Docker@2
displayName: 'Build an image'
inputs:
command: 'build'
containerRegistry: 'Docker Hub'
dockerFile: 'WebDisplay/Dockerfile'
repository: 'ckaczor/home-monitor-web-display'
tags: '$(Build.BuildNumber),latest'
- task: Docker@2
displayName: 'Push an image'
inputs:
command: 'push'
containerRegistry: 'Docker Hub'
repository: 'ckaczor/home-monitor-web-display'
tags: '$(Build.BuildNumber),latest'
- task: Bash@3
inputs:
targetType: 'inline'
script: 'sed -i s/#BUILD_BUILDNUMBER#/$BUILD_BUILDNUMBER/ WebDisplay/deploy/manifest.yaml'
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: 'WebDisplay/deploy/manifest.yaml'
ArtifactName: 'Manifest'
publishLocation: 'Container'
- task: Bash@3
inputs:
targetType: 'inline'
script: 'sed -i s/#BUILD_BUILDNUMBER#/$BUILD_BUILDNUMBER/ WebDisplay/deploy/manifest-internal.yaml'
- task: PublishBuildArtifacts@1
inputs:
PathtoPublish: 'WebDisplay/deploy/manifest-internal.yaml'
ArtifactName: 'Manifest-Internal'
publishLocation: 'Container'
- stage: Deploy
jobs:
- job: Deploy
pool:
vmImage: 'ubuntu-latest'
steps:
- task: DownloadBuildArtifacts@0
inputs:
artifactName: 'Manifest'
buildType: 'current'
downloadType: 'single'
downloadPath: '$(System.ArtifactsDirectory)'
- task: Kubernetes@1
inputs:
connectionType: 'Kubernetes Service Connection'
kubernetesServiceEndpoint: 'Kubernetes'
namespace: 'home-monitor'
command: 'apply'
useConfigurationFile: true
configuration: '$(System.ArtifactsDirectory)/Manifest/manifest.yaml'
secretType: 'dockerRegistry'
containerRegistryType: 'Container Registry'
- stage: Deploy
jobs:
- job: Deploy
pool:
vmImage: 'ubuntu-latest'
steps:
- task: DownloadBuildArtifacts@0
inputs:
artifactName: 'Manifest'
buildType: 'current'
downloadType: 'single'
downloadPath: '$(System.ArtifactsDirectory)'
- task: Kubernetes@1
inputs:
connectionType: 'Kubernetes Service Connection'
kubernetesServiceEndpoint: 'Kubernetes'
namespace: 'home-monitor'
command: 'apply'
useConfigurationFile: true
configuration: '$(System.ArtifactsDirectory)/Manifest/manifest.yaml'
secretType: 'dockerRegistry'
containerRegistryType: 'Container Registry'
- job: Deploy_Internal
pool:
vmImage: 'ubuntu-latest'
steps:
- task: DownloadBuildArtifacts@0
inputs:
artifactName: 'Manifest-Internal'
buildType: 'current'
downloadType: 'single'
downloadPath: '$(System.ArtifactsDirectory)'
- task: Kubernetes@1
inputs:
connectionType: 'Kubernetes Service Connection'
kubernetesServiceEndpoint: 'Kubernetes'
namespace: 'home-monitor'
command: 'apply'
useConfigurationFile: true
configuration: '$(System.ArtifactsDirectory)/Manifest-Internal/manifest-internal.yaml'
secretType: 'dockerRegistry'
containerRegistryType: 'Container Registry'

View File

@@ -0,0 +1,105 @@
---
kind: Deployment
apiVersion: apps/v1
metadata:
name: display-internal
namespace: home-monitor
labels:
app: display-internal
spec:
replicas: 1
selector:
matchLabels:
app: display-internal
template:
metadata:
labels:
app: display-internal
spec:
containers:
- name: display-internal
image: ckaczor/home-monitor-web-display:#BUILD_BUILDNUMBER#
terminationMessagePath: "/dev/termination-log"
terminationMessagePolicy: File
imagePullPolicy: Always
securityContext:
privileged: true
env:
- name: API_PREFIX
value: http://172.23.10.3
- name: HOME_ASSISTANT_URL
valueFrom:
secretKeyRef:
name: display-internal-config
key: HOME_ASSISTANT_URL
- name: HOME_ASSISTANT_TOKEN
valueFrom:
secretKeyRef:
name: display-internal-config
key: HOME_ASSISTANT_TOKEN
- name: GARAGE_DEVICE
valueFrom:
secretKeyRef:
name: display-internal-config
key: GARAGE_DEVICE
- name: ALARM_DEVICE
valueFrom:
secretKeyRef:
name: display-internal-config
key: ALARM_DEVICE
- name: CALENDAR_EMBED_URL
valueFrom:
secretKeyRef:
name: display-internal-config
key: CALENDAR_EMBED_URL
restartPolicy: Always
terminationGracePeriodSeconds: 30
dnsPolicy: ClusterFirst
nodeSelector:
kubernetes.io/hostname: kubernetes
schedulerName: default-scheduler
---
kind: Service
apiVersion: v1
metadata:
name: display-internal
spec:
ports:
- name: client
port: 9001
targetPort: 80
selector:
app: display-internal
type: ClusterIP
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
annotations:
kubernetes.io/ingress.class: traefik
creationTimestamp: null
name: display-internal
namespace: home-monitor
spec:
entryPoints:
- display
routes:
- kind: Rule
match: PathPrefix(`/`)
priority: 101
services:
- kind: Service
name: display-internal
namespace: home-monitor
port: 9001
---
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
creationTimestamp: null
name: display-internal
namespace: home-monitor
spec:
stripPrefix:
prefixes:
- /

View File

@@ -24,6 +24,19 @@ spec:
imagePullPolicy: Always
securityContext:
privileged: true
env:
- name: API_PREFIX
value:
- name: HOME_ASSISTANT_URL
value:
- name: HOME_ASSISTANT_TOKEN
value:
- name: GARAGE_DEVICE
value:
- name: ALARM_DEVICE
value:
- name: CALENDAR_EMBED_URL
value:
restartPolicy: Always
terminationGracePeriodSeconds: 30
dnsPolicy: ClusterFirst

View File

@@ -0,0 +1,7 @@
#!/bin/sh
sed -i -e "s~#API_PREFIX#~$API_PREFIX~g" /usr/share/nginx/html/assets/*
sed -i -e "s~#HOME_ASSISTANT_URL#~$HOME_ASSISTANT_URL~g" /usr/share/nginx/html/assets/*
sed -i -e "s~#HOME_ASSISTANT_TOKEN#~$HOME_ASSISTANT_TOKEN~g" /usr/share/nginx/html/assets/*
sed -i -e "s~#GARAGE_DEVICE#~$GARAGE_DEVICE~g" /usr/share/nginx/html/assets/*
sed -i -e "s~#ALARM_DEVICE#~$ALARM_DEVICE~g" /usr/share/nginx/html/assets/*

View File

@@ -1,6 +1,7 @@
{
"name": "web-display",
"version": "1.0.0",
"packageManager": "pnpm@10.29.3",
"scripts": {
"dev": "cross-env NODE_OPTIONS='--no-warnings' vite",
"build": "vue-tsc --noEmit && vite build",
@@ -9,41 +10,42 @@
},
"dependencies": {
"@mdi/font": "7.0.96",
"@microsoft/signalr": "^8.0.0",
"@microsoft/signalr": "^8.0.7",
"@types/suncalc": "^1.9.2",
"@vuepic/vue-datepicker": "^8.2.0",
"apexcharts": "^3.46.0",
"axios": "^1.6.7",
"core-js": "^3.34.0",
"date-fns": "^3.3.1",
"pinia": "^2.1.7",
"roboto-fontface": "*",
"@vuepic/vue-datepicker": "^8.8.1",
"apexcharts": "^4.1.0",
"axios": "^1.7.9",
"core-js": "^3.39.0",
"date-fns": "^3.6.0",
"home-assistant-js-websocket": "^9.4.0",
"pinia": "^2.3.0",
"roboto-fontface": "^0.10.0",
"suncalc": "^1.9.0",
"vue": "^3.3.0",
"vue3-apexcharts": "^1.5.2",
"vuetify": "^3.0.0"
"vue": "^3.5.13",
"vue3-apexcharts": "^1.8.0",
"vuetify": "^3.7.5"
},
"devDependencies": {
"@babel/types": "^7.23.0",
"@types/node": "^20.10.0",
"@vitejs/plugin-vue": "^4.5.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@babel/types": "^7.26.3",
"@types/node": "^20.17.9",
"@typescript-eslint/parser": "8.17.0",
"@vitejs/plugin-vue": "^4.6.2",
"@vue/eslint-config-typescript": "^14.1.4",
"cross-env": "^7.0.3",
"eslint": "^8.56.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-n": "^16.4.0",
"eslint": "^9.16.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-vue": "^9.19.0",
"sass": "^1.69.0",
"typescript": "^5.3.0",
"unplugin-fonts": "^1.1.0",
"eslint-plugin-promise": "^6.6.0",
"eslint-plugin-vue": "^9.32.0",
"sass": "^1.82.0",
"typescript": "5.6.2",
"unplugin-fonts": "^1.3.1",
"unplugin-vue-components": "^0.26.0",
"unplugin-vue-router": "^0.7.0",
"vite": "^5.0.0",
"vite-plugin-vuetify": "^2.0.0",
"vue-router": "^4.2.0",
"vue-tsc": "^1.8.0"
"vite": "^5.4.11",
"vite-plugin-vuetify": "^2.0.4",
"vue-router": "^4.5.0",
"vue-tsc": "^2.1.10"
}
}

5444
WebDisplay/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +1,7 @@
<template>
<v-app>
<v-app-bar
title="Home Monitor"
color="primary">
<template v-slot:prepend>
<v-app-bar-nav-icon
v-show="!mdAndUp"
@click="drawer = !drawer"></v-app-bar-nav-icon>
</template>
</v-app-bar>
<v-navigation-drawer
mobile-breakpoint="md"
:expand-on-hover="mdAndUp"
:rail="mdAndUp"
:model-value="mdAndUp ? true : drawer">
<v-list
density="compact"
nav>
<v-list-item
prepend-icon="mdi-information-outline"
title="Current"
to="/"
@click="drawer = false">
</v-list-item>
<v-list-item
prepend-icon="mdi-chart-box-outline"
title="Summary"
to="summary"
@click="drawer = false">
</v-list-item>
<v-list-item
prepend-icon="mdi-sun-thermometer"
title="Outdoor"
to="outdoor"
@click="drawer = false">
</v-list-item>
<v-list-item
prepend-icon="mdi-home-analytics"
title="Indoor"
to="indoor"
@click="drawer = false">
</v-list-item>
<v-list-item
prepend-icon="mdi-home-lightning-bolt-outline"
title="Power"
to="power"
@click="drawer = false">
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-main>
<router-view />
</v-main>
<router-view></router-view>
</v-app>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useDisplay } from 'vuetify';
const drawer = ref(false);
const { mdAndUp } = useDisplay();
</script>
<script lang="ts" setup></script>

View File

@@ -0,0 +1,155 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useCalendarStore } from '@/stores/calendarStore';
import { format, startOfDay, endOfDay } from 'date-fns';
import { setNextDayTimer } from '@/nextDayTimer';
import CalendarDay from '@/models/calendar/calendar-day';
const props = defineProps(['days', 'refreshInterval']);
const calendarStore = useCalendarStore();
const calendarDays = ref([] as CalendarDay[]);
const calendarReady = ref(false);
function loadCalendar() {
const newCalendarDays = [] as CalendarDay[];
calendarStore.getUpcoming(props.days, true).then((upcoming) => {
const currentDay = startOfDay(new Date());
for (let i = 0; i < props.days; i++) {
const day = new Date(currentDay);
day.setDate(day.getDate() + i);
const entries = upcoming
.filter((entry) => {
const entryStart = startOfDay(entry.start);
const entryEnd = endOfDay(entry.end);
if (entry.isAllDay) {
return day > entryStart && day < entryEnd;
}
return day >= entryStart && day <= entryEnd;
})
.sort((a, b) => {
if (a.isHoliday == b.isHoliday) {
return a.summary.localeCompare(b.summary);
}
return (b.isHoliday ? 1 : 0) - (a.isHoliday ? 1 : 0);
});
newCalendarDays.push(new CalendarDay(day, entries));
}
calendarDays.value = newCalendarDays;
calendarReady.value = true;
setNextDayTimer(loadCalendar, 10000);
});
}
loadCalendar();
setInterval(loadCalendar, props.refreshInterval);
</script>
<template>
<div
class="calendar"
v-if="calendarReady">
<div class="calendar-header">
{{ 'Next ' + days + ' Days' }}
</div>
<ul class="calendar-day-list">
<li
class="calendar-day-item"
v-for="calendarDay in calendarDays">
<div>
<div class="calendar-day-item-header">
<span class="calendar-day-item-number">
{{ format(calendarDay.date, 'd') }}
</span>
<span class="calendar-day-item-name">
{{ format(calendarDay.date, 'EEEE') }}
</span>
</div>
<ul
class="calendar-entry"
v-for="calendarEntry in calendarDay.entries"
:class="{ 'calendar-holiday': calendarEntry.isHoliday }">
<span>
{{ calendarEntry.summary }}
<span v-if="!calendarEntry.isAllDay">@ {{ format(calendarEntry.start, 'p') }}</span>
</span>
</ul>
</div>
</li>
</ul>
</div>
</template>
<style>
.calendar {
background-color: #121212;
border-radius: 10px;
display: flex;
flex: 1;
flex-direction: column;
}
.calendar-header {
font-size: 1.15em;
padding-top: 10px;
padding-bottom: 2px;
text-align: center;
}
.calendar-day-item-header {
display: flex;
align-items: center;
}
.calendar-day-item-number {
font-size: 1.25em;
padding-right: 0.5em;
width: 2rem;
text-align: right;
}
.calendar-day-item-name {
font-size: 1em;
}
.calendar-day-list {
margin-left: 10px;
overflow: auto;
flex: 1;
scrollbar-width: none;
}
.calendar-day-item:not(:last-child) {
padding-bottom: 2px;
}
.calendar-day-item:first-of-type {
color: #c75ec7;
}
.calendar-day-item:not(:first-child) {
padding-top: 4px;
}
.calendar-entry {
color: #ebebeb;
padding-left: 2em;
padding-bottom: 2px;
}
.calendar-holiday {
color: #5e83c7;
}
</style>

View File

@@ -1,28 +1,10 @@
<script setup lang="ts">
import { useWeatherStore } from '@/stores/weatherStore';
import { ConvertPascalToInchesOfMercury } from '@/pressureConverter';
import { ShortenWindDirection } from '@/windFormatter';
import PressureTrendArrow from './PressureTrendArrow.vue';
const weatherStore = useWeatherStore();
weatherStore.start();
const rotationClass = (pressureDifference: number | undefined) => {
if (!pressureDifference) {
return '';
} else if (Math.abs(pressureDifference) <= 1.0) {
return '';
} else if (pressureDifference > 1.0 && pressureDifference <= 2.0) {
return 'up-low';
} else if (pressureDifference > 2.0) {
return 'up-high';
} else if (pressureDifference < -1.0 && pressureDifference >= -2.0) {
return 'down-low';
} else if (pressureDifference < -2.0) {
return 'down-high';
}
return '';
};
</script>
<template>
@@ -65,13 +47,10 @@
<tr>
<td className="weather-current-header">Pressure</td>
<td colspan="3">
{{ weatherStore.current?.Pressure && ConvertPascalToInchesOfMercury(weatherStore.current?.Pressure)?.toFixed(2) }}"
<span
{{ weatherStore.current?.Pressure && (weatherStore.current?.Pressure / 100).toFixed(2) }} mbar
<PressureTrendArrow
class="pressure-trend-arrow"
:class="rotationClass(weatherStore.current?.PressureDifferenceThreeHour)"
:title="'3 Hour Change: ' + weatherStore.current?.PressureDifferenceThreeHour?.toFixed(1)">
</span>
:pressureDifference="weatherStore.current?.PressureDifferenceThreeHour" />
</td>
</tr>
<tr>
@@ -115,25 +94,6 @@
}
.pressure-trend-arrow {
display: inline-block;
position: relative;
left: 6px;
transform: scale(1.25);
}
.down-high {
transform: rotate(60deg) scale(1.25);
}
.down-low {
transform: rotate(25deg) scale(1.25);
}
.up-high {
transform: rotate(-60deg) scale(1.25);
}
.up-low {
transform: rotate(-25deg) scale(1.25);
scale: 1.25;
}
</style>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import { ref } from 'vue';
const emit = defineEmits(['longPress']);
const props = defineProps(['duration', 'increment', 'progressSize']);
const current = ref(0 as number);
const loading = ref(false);
let interval: NodeJS.Timeout;
function startProgress(pointerEvent: PointerEvent) {
(pointerEvent.target as HTMLButtonElement).setPointerCapture(pointerEvent.pointerId);
loading.value = true;
incrementProgress();
interval = setInterval(incrementProgress, props.increment);
}
function stopProgress() {
clearInterval(interval);
loading.value = false;
current.value = 0;
}
function incrementProgress() {
current.value = current.value + props.increment;
if (current.value >= props.duration) {
emit('longPress');
stopProgress();
}
}
function progress() {
if (current.value >= props.duration) {
return 100;
}
return (current.value / props.duration) * 100;
}
</script>
<template>
<button
class="long-press-button"
:class="{ loading: loading }"
@pointerdown="startProgress"
@pointerup="stopProgress"
@pointercancel="stopProgress">
<span v-show="!loading">
<slot name="default"></slot>
</span>
<v-progress-circular
v-if="loading"
:size="props.progressSize"
:model-value="progress()"></v-progress-circular>
</button>
</template>
<style scoped>
.long-press-button {
touch-action: none;
}
.loading {
display: inline-flex;
flex-direction: column;
align-items: center;
}
</style>
<style>
.v-progress-circular__underlay {
stroke: #ebebeb !important;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useCalendarStore } from '@/stores/calendarStore';
import { setNextDayTimer } from '@/nextDayTimer';
import NationalDayEntry from '@/models/calendar/national-day';
const dialog = ref(false);
const selectedNationalDay = ref({} as NationalDayEntry);
const calendarStore = useCalendarStore();
const nationalDays = ref([] as NationalDayEntry[]);
const nationalDaysReady = ref(false);
function loadNationalDays() {
calendarStore.getNationalDays().then((data) => {
nationalDays.value = data;
nationalDaysReady.value = true;
setNextDayTimer(loadNationalDays, 10000);
});
}
function onNationalDayClick(nationalDay: NationalDayEntry) {
selectedNationalDay.value = nationalDay;
dialog.value = true;
}
loadNationalDays();
</script>
<template>
<div
class="national-days"
v-if="nationalDaysReady">
<div class="national-days-header">
{{ 'National Days (' + nationalDays.length + ')' }}
</div>
<ul class="national-days-list">
<li
class="national-day"
v-for="nationalDay in nationalDays"
@click="onNationalDayClick(nationalDay)">
<span v-html="nationalDay.name"></span>
<v-icon
class="national-day-arrow"
icon="mdi-menu-right" />
</li>
</ul>
<v-dialog
v-model="dialog"
width="50%"
theme="dark"
opacity="0.85"
scrim="black">
<v-card>
<template v-slot:title>
<span v-html="selectedNationalDay.name"></span>
</template>
<template v-slot:text>
<span v-html="selectedNationalDay.excerpt"></span>
</template>
<template v-slot:actions>
<v-btn
class="ms-auto"
text="Close"
@click="dialog = false"></v-btn>
</template>
</v-card>
</v-dialog>
</div>
</template>
<style>
.national-days {
background-color: #121212;
border-radius: 10px;
padding: 10px;
display: flex;
flex: 1;
flex-direction: column;
}
.national-days-header {
font-size: 1.15em;
padding-bottom: 4px;
text-align: center;
}
.national-days-list {
overflow: auto;
flex: 1;
scrollbar-width: none;
}
.national-day {
list-style-type: none;
line-height: 1.75rem;
}
.national-day-arrow {
float: right;
}
</style>

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
const props = defineProps(['pressureDifference']);
function rotationClass(pressureDifference: number | undefined) {
if (!pressureDifference) {
return '';
} else if (Math.abs(pressureDifference) <= 1.0) {
return '';
} else if (pressureDifference > 1.0 && pressureDifference <= 2.0) {
return 'up-low';
} else if (pressureDifference > 2.0) {
return 'up-high';
} else if (pressureDifference < -1.0 && pressureDifference >= -2.0) {
return 'down-low';
} else if (pressureDifference < -2.0) {
return 'down-high';
}
return '';
}
</script>
<template>
<span
class="pressure-trend-arrow"
:class="rotationClass(props.pressureDifference)"
:title="'3 Hour Change: ' + props.pressureDifference.toFixed(1)">
</span>
</template>
<style scoped>
.pressure-trend-arrow {
display: inline-block;
}
.down-high {
transform: rotate(60deg);
}
.down-low {
transform: rotate(25deg);
}
.up-high {
transform: rotate(-60deg);
}
.up-low {
transform: rotate(-25deg);
}
</style>

View File

@@ -0,0 +1,128 @@
<script lang="ts" setup>
import WindDirection from '@/models/weather/wind-direction';
const props = defineProps(['windDirection']);
function rotationClass(windDirection: WindDirection | undefined) {
switch (windDirection) {
case WindDirection.None:
return 'None';
case WindDirection.North:
return 'N';
case WindDirection.East:
return 'E';
case WindDirection.South:
return 'S';
case WindDirection.West:
return 'W';
case WindDirection.NorthEast:
return 'NE';
case WindDirection.SouthEast:
return 'SE';
case WindDirection.SouthWest:
return 'SW';
case WindDirection.NorthWest:
return 'NW';
case WindDirection.NorthNorthEast:
return 'NNE';
case WindDirection.EastNorthEast:
return 'ENE';
case WindDirection.EastSouthEast:
return 'ESE';
case WindDirection.SouthSouthEast:
return 'SSE';
case WindDirection.SouthSouthWest:
return 'SSW';
case WindDirection.WestSouthWest:
return 'WSW';
case WindDirection.WestNorthWest:
return 'WNW';
case WindDirection.NorthNorthWest:
return 'NNW';
}
return windDirection!.toString();
}
</script>
<template>
<span
class="wind-direction-arrow"
:class="rotationClass(props.windDirection)"
:title="props.windDirection">
</span>
</template>
<style scoped>
.wind-direction-arrow {
display: inline-block;
}
.None {
display: none;
}
.N {
transform: rotate(calc(-90deg + 0deg));
}
.E {
transform: rotate(calc(-90deg + 90deg));
}
.S {
transform: rotate(calc(-90deg + 180deg));
}
.W {
transform: rotate(calc(-90deg + 270deg));
}
.NE {
transform: rotate(calc(-90deg + 45deg));
}
.SE {
transform: rotate(calc(-90deg + 135deg));
}
.SW {
transform: rotate(calc(-90deg + 225deg));
}
.NW {
transform: rotate(calc(-90deg + 315deg));
}
.NNE {
transform: rotate(calc(-90deg + 22.5deg));
}
.ENE {
transform: rotate(calc(-90deg + 67.5deg));
}
.ESE {
transform: rotate(calc(-90deg + 112.5deg));
}
.SSE {
transform: rotate(calc(-90deg + 157.5deg));
}
.SSW {
transform: rotate(calc(-90deg + 202.5deg));
}
.WSW {
transform: rotate(calc(-90deg + 247.5deg));
}
.WNW {
transform: rotate(calc(-90deg + 292.5deg));
}
.NNW {
transform: rotate(calc(-90deg + 337.5deg));
}
</style>

View File

@@ -1,3 +0,0 @@
{
"API_PREFIX": ""
}

View File

@@ -0,0 +1,15 @@
export function dateReviver(key: string, value: any): any {
if (key.indexOf('Timestamp') == -1) {
return value;
}
if (typeof value === 'string' && value.match(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)?$/)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date;
}
}
return value;
}

View File

@@ -1,7 +1,21 @@
import config from './config.json';
export default class Environment {
public static getUrlPrefix(): string {
return config.API_PREFIX;
return '#API_PREFIX#';
}
public static getHomeAssistantUrl(): string {
return '#HOME_ASSISTANT_URL#';
}
public static getHomeAssistantToken(): string {
return '#HOME_ASSISTANT_TOKEN#';
}
public static getGarageDevice(): string {
return '#GARAGE_DEVICE#';
}
public static getAlarmDevice(): string {
return '#ALARM_DEVICE#';
}
}

View File

@@ -0,0 +1,11 @@
import CalendarEntry from './calendar-entry';
export default class CalendarDay {
date: Date;
entries: CalendarEntry[];
constructor(date: Date, entries: CalendarEntry[]) {
this.date = date;
this.entries = entries;
}
}

View File

@@ -0,0 +1,7 @@
export default interface CalendarEntry {
summary: string;
isAllDay: boolean;
start: Date;
end: Date;
isHoliday: boolean;
}

View File

@@ -0,0 +1,6 @@
export default interface NationalDayEntry {
name: string;
url: string;
excerpt: string;
type: string;
}

View File

@@ -1,4 +1,5 @@
export default class PowerStatus {
Timestamp: Date | undefined;
Generation: number = 0;
Consumption: number = 0;
}

View File

@@ -0,0 +1,12 @@
import { addDays, startOfDay } from 'date-fns';
type CallbackFunction = () => void;
export function setNextDayTimer(callback: CallbackFunction, offset: number): void {
const now = new Date();
const startOfNextDay = startOfDay(addDays(now, 1));
const millisecondsUntilNextDay = startOfNextDay.getTime() - now.getTime() + offset;
setTimeout(callback, millisecondsUntilNextDay);
}

View File

@@ -1,104 +1,68 @@
<script lang="ts" setup></script>
<template>
<v-container
fluid
class="container">
<v-card class="current-weather">
<CurrentWeather></CurrentWeather>
</v-card>
<v-card class="almanac">
<Almanac></Almanac>
</v-card>
<v-card class="current-power">
<CurrentPower></CurrentPower>
</v-card>
<v-card class="current-laundry-status">
<CurrentLaundryStatus></CurrentLaundryStatus>
</v-card>
<v-card class="upstairs">
<Indoor
title="Upstairs"
deviceName="main"></Indoor>
</v-card>
<v-card class="downstairs">
<Indoor
title="Downstairs"
deviceName="basement"></Indoor>
</v-card>
</v-container>
<v-app>
<v-app-bar
title="Home Monitor"
color="primary">
<template v-slot:prepend>
<v-app-bar-nav-icon
v-show="!mdAndUp"
@click="drawer = !drawer"></v-app-bar-nav-icon>
</template>
</v-app-bar>
<v-navigation-drawer
mobile-breakpoint="md"
:expand-on-hover="mdAndUp"
:rail="mdAndUp"
:model-value="mdAndUp ? true : drawer">
<v-list
density="compact"
nav>
<v-list-item
prepend-icon="mdi-information-outline"
title="Current"
:active="$route.path === '/'"
to="/"
@click="drawer = false">
</v-list-item>
<v-list-item
prepend-icon="mdi-chart-box-outline"
title="Summary"
to="summary"
@click="drawer = false">
</v-list-item>
<v-list-item
prepend-icon="mdi-sun-thermometer"
title="Outdoor"
to="outdoor"
@click="drawer = false">
</v-list-item>
<v-list-item
prepend-icon="mdi-home-analytics"
title="Indoor"
to="indoor"
@click="drawer = false">
</v-list-item>
<v-list-item
prepend-icon="mdi-home-lightning-bolt-outline"
title="Power"
to="power"
@click="drawer = false">
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-main>
<router-view />
</v-main>
</v-app>
</template>
<style scoped>
.container {
height: 100%;
background-color: #fafafa;
}
<script lang="ts" setup>
import { ref } from 'vue';
import { useDisplay } from 'vuetify';
@media (min-width: 1024px) {
.container {
display: grid;
grid-template-columns: 360px 350px 210px 1fr;
grid-template-rows: repeat(3, max-content);
gap: 15px 15px;
grid-auto-flow: row;
grid-template-areas:
'current-weather almanac current-laundry-status'
'current-weather almanac current-power'
'upstairs downstairs .';
}
}
const drawer = ref(false);
@media (max-width: 1024px) {
.container {
display: grid;
grid-template-columns: repeat(4, 180px);
grid-template-rows: repeat(2, max-content);
gap: 15px 15px;
grid-auto-flow: row;
grid-template-areas:
'current-weather current-weather almanac almanac'
'current-power current-laundry-status upstairs downstairs';
}
}
@media (max-width: 768px) {
.container {
display: grid;
grid-template-columns: repeat(1, 1fr);
grid-template-rows: repeat(6, max-content);
gap: 10px 0px;
grid-template-areas:
'current-weather'
'almanac'
'current-power'
'current-laundry-status'
'upstairs'
'downstairs';
}
}
.current-weather {
grid-area: current-weather;
}
.almanac {
grid-area: almanac;
}
.current-power {
grid-area: current-power;
}
.current-laundry-status {
grid-area: current-laundry-status;
}
.upstairs {
grid-area: upstairs;
}
.downstairs {
grid-area: downstairs;
}
</style>
const { mdAndUp } = useDisplay();
</script>

View File

@@ -0,0 +1,104 @@
<script lang="ts" setup></script>
<template>
<v-container
fluid
class="container">
<v-card class="current-weather">
<CurrentWeather></CurrentWeather>
</v-card>
<v-card class="almanac">
<Almanac></Almanac>
</v-card>
<v-card class="current-power">
<CurrentPower></CurrentPower>
</v-card>
<v-card class="current-laundry-status">
<CurrentLaundryStatus></CurrentLaundryStatus>
</v-card>
<v-card class="upstairs">
<Indoor
title="Upstairs"
deviceName="main"></Indoor>
</v-card>
<v-card class="downstairs">
<Indoor
title="Downstairs"
deviceName="basement"></Indoor>
</v-card>
</v-container>
</template>
<style scoped>
.container {
height: 100%;
background-color: #fafafa;
}
@media (min-width: 1024px) {
.container {
display: grid;
grid-template-columns: 360px 350px 210px 1fr;
grid-template-rows: repeat(3, max-content);
gap: 15px 15px;
grid-auto-flow: row;
grid-template-areas:
'current-weather almanac current-laundry-status'
'current-weather almanac current-power'
'upstairs downstairs .';
}
}
@media (max-width: 1024px) {
.container {
display: grid;
grid-template-columns: repeat(4, 180px);
grid-template-rows: repeat(2, max-content);
gap: 15px 15px;
grid-auto-flow: row;
grid-template-areas:
'current-weather current-weather almanac almanac'
'current-power current-laundry-status upstairs downstairs';
}
}
@media (max-width: 768px) {
.container {
display: grid;
grid-template-columns: repeat(1, 1fr);
grid-template-rows: repeat(6, max-content);
gap: 10px 0px;
grid-template-areas:
'current-weather'
'almanac'
'current-power'
'current-laundry-status'
'upstairs'
'downstairs';
}
}
.current-weather {
grid-area: current-weather;
}
.almanac {
grid-area: almanac;
}
.current-power {
grid-area: current-power;
}
.current-laundry-status {
grid-area: current-laundry-status;
}
.upstairs {
grid-area: upstairs;
}
.downstairs {
grid-area: downstairs;
}
</style>

View File

@@ -4,7 +4,7 @@
import { createIndoorStore } from '@/stores/indoorStore';
import { ConvertCToF } from '@/temperatureConverter';
import { ConvertMillibarToInchesOfMercury } from '@/pressureConverter';
import ValueChart from '../components/ValueChart.vue';
import ValueChart from '@/components/ValueChart.vue';
import TimeRange from '@/components/TimeRange.vue';
import TimeSpan from '@/models/time-span';

View File

@@ -3,7 +3,7 @@
import { subHours } from 'date-fns';
import { useWeatherStore } from '@/stores/weatherStore';
import { ConvertPascalToInchesOfMercury } from '@/pressureConverter';
import ValueChart from '../components/ValueChart.vue';
import ValueChart from '@/components/ValueChart.vue';
import TimeRange from '@/components/TimeRange.vue';
import TimeSpan from '@/models/time-span';
import { ConvertDegreesToShortLabel, ConvertWindDirectionToDegrees } from '@/windConverter';

View File

@@ -3,7 +3,7 @@
import { subHours } from 'date-fns';
import { usePowerStore } from '@/stores/powerStore';
import { useWeatherStore } from '@/stores/weatherStore';
import ValueChart from '../components/ValueChart.vue';
import ValueChart from '@/components/ValueChart.vue';
import TimeRange from '@/components/TimeRange.vue';
import WeatherValueType from '@/models/weather/weather-value-type';
import TimeSpan from '@/models/time-span';

View File

@@ -0,0 +1,409 @@
<script lang="ts" setup>
import { capitalize, ref } from 'vue';
import { useWeatherStore } from '@/stores/weatherStore';
import { useLaundryStore } from '@/stores/laundryStore';
import { usePowerStore } from '@/stores/powerStore';
import { useHomeAssistantStore } from '@/stores/homeAssistantStore';
import CalendarAgenda from '@/components/CalendarAgenda.vue';
import LongPressButton from '@/components/LongPressButton.vue';
import PressureTrendArrow from '@/components/PressureTrendArrow.vue';
const outOfDateDuration = 5000;
const showFeelsLike = ref(true);
const weatherStore = useWeatherStore();
weatherStore.start();
const laundryStore = useLaundryStore();
laundryStore.start();
const powerStore = usePowerStore();
powerStore.start();
const homeAssistantStore = useHomeAssistantStore();
homeAssistantStore.start();
const currentTime = ref(new Date());
const timeFormatter = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
const dateFormatter = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
function alarmState(state: string): string {
switch (state) {
case 'armed_home':
return 'Armed';
case 'armed_away':
return 'Armed';
case 'disarmed':
return 'Disarmed';
default:
return 'Unknown';
}
}
function getTemperature(): string {
if (showFeelsLike.value && weatherStore.current?.WindChill) {
return weatherStore.current?.WindChill?.toFixed(0) + '°';
} else if (showFeelsLike.value && weatherStore.current?.HeatIndex) {
return weatherStore.current?.HeatIndex?.toFixed(0) + '°';
} else {
return weatherStore.current?.Temperature?.toFixed(0) + '°';
}
}
function weatherOutOfDate(): boolean {
return weatherStore.current?.Timestamp !== undefined && Date.now() - weatherStore.current.Timestamp.getTime() >= outOfDateDuration;
}
function powerOutOfDate(): boolean {
return powerStore.current?.Timestamp !== undefined && Date.now() - powerStore.current.Timestamp.getTime() >= outOfDateDuration;
}
function getTemperatureClass(): string {
if (weatherOutOfDate()) {
return 'out-of-date-reading';
} else if (showFeelsLike.value && weatherStore.current?.WindChill) {
return 'temperature-wind-chill';
} else if (showFeelsLike.value && weatherStore.current?.HeatIndex) {
return 'temperature-heat-index';
} else {
return '';
}
}
function dewPointDescription(dewPoint: number | undefined): string {
if (dewPoint === undefined) {
return '';
} else if (dewPoint < 55) {
return 'Dry';
} else if (dewPoint <= 60) {
return 'Comfortable';
} else if (dewPoint <= 65) {
return 'Slightly Humid';
} else if (dewPoint <= 70) {
return 'Humid';
} else if (dewPoint <= 75) {
return 'Very Humid';
} else if (dewPoint > 75) {
return 'Oppressive';
}
return '';
}
setInterval(() => (currentTime.value = new Date()), 1000);
</script>
<template>
<v-container
fluid
class="kiosk-container">
<div class="kiosk-sidebar">
<div
name="kiosk-time"
class="kiosk-time text-center">
{{ timeFormatter.format(currentTime) }}
</div>
<div class="kiosk-date text-center pb-4">
{{ dateFormatter.format(currentTime) }}
</div>
<div
class="kiosk-temperature text-center pb-3"
:class="getTemperatureClass()"
@click="showFeelsLike = !showFeelsLike"
v-if="weatherStore.current">
<div class="display-item">
<span class="display-item-header">Temperature</span>
<span class="display-item-value">{{ getTemperature() }}</span>
<span class="display-item-label">{{ showFeelsLike ? 'Feels Like' : 'Actual' }}</span>
</div>
</div>
<div
class="kiosk-humidity text-center pb-3"
:class="{ 'out-of-date-reading': weatherOutOfDate() }"
v-if="weatherStore.current">
<div class="display-item">
<span class="display-item-header">Dew Point</span>
<span class="display-item-value">
{{ weatherStore.current?.DewPoint?.toFixed(0) + '°' }}
</span>
<span class="display-item-label">{{ dewPointDescription(weatherStore.current?.DewPoint) }}</span>
</div>
</div>
<div
class="kiosk-wind text-center pb-3"
:class="{ 'out-of-date-reading': weatherOutOfDate() }"
v-if="weatherStore.current">
<div class="display-item">
<span class="display-item-header">Wind</span>
<span class="display-item-value">
<span>
{{ weatherStore.current?.WindSpeed?.toFixed(1) }}
</span>
<WindDirectionArrow :windDirection="weatherStore.current?.WindDirection" />
</span>
<span class="display-item-label">MPH</span>
</div>
</div>
<div
class="kiosk-pressure text-center pb-3"
:class="{ 'out-of-date-reading': weatherOutOfDate() }"
v-if="weatherStore.current">
<div class="display-item">
<span class="display-item-header">Pressure</span>
<span class="display-item-value">
<span>
{{ (weatherStore.current?.Pressure! / 100).toFixed(0) }}
</span>
<PressureTrendArrow :pressureDifference="weatherStore.current?.PressureDifferenceThreeHour" />
</span>
<span class="display-item-label">hPa</span>
</div>
</div>
<div
class="kiosk-generation text-center pt-4"
:class="{ 'out-of-date-reading': powerOutOfDate() }"
v-if="powerStore.current">
<v-icon
class="kiosk-device-icon"
icon="mdi-solar-power-variant" />
<div class="kiosk-device-text">
{{ (powerStore.current!.Generation < 0 ? 0 : powerStore.current!.Generation) + '&thinsp;W' }}
</div>
</div>
<div
class="kiosk-consumption text-center pt-4"
:class="{ 'out-of-date-reading': powerOutOfDate() }"
v-if="powerStore.current">
<v-icon
class="kiosk-device-icon"
icon="mdi-home-lightning-bolt" />
<div class="kiosk-device-text">
{{ powerStore.current.Consumption + '&thinsp;W' }}
</div>
</div>
<div
class="kiosk-washer text-center pt-4"
v-if="laundryStore?.current?.washer !== undefined"
:class="laundryStore.current.washer ? 'warning' : 'normal'">
<v-icon
class="kiosk-device-icon"
icon="mdi-washing-machine" />
<div class="kiosk-device-text">
{{ laundryStore.current.washer ? 'On' : 'Off' }}
</div>
</div>
<div
class="kiosk-dryer text-center pt-4"
v-if="laundryStore?.current?.dryer !== undefined"
:class="laundryStore.current.dryer ? 'warning' : 'normal'">
<v-icon
class="kiosk-device-icon"
icon="mdi-tumble-dryer" />
<div class="kiosk-device-text">
{{ laundryStore.current.dryer ? 'On' : 'Off' }}
</div>
</div>
<LongPressButton
class="kiosk-garage-door text-center pt-4"
v-if="homeAssistantStore?.garageState"
:duration="2000"
:increment="100"
:progress-size="38"
v-on:longPress="homeAssistantStore.toggleGarage()"
:class="homeAssistantStore.garageState === 'closed' ? 'normal' : 'warning'">
<v-icon
class="kiosk-device-icon"
:icon="homeAssistantStore.garageState === 'closed' ? 'mdi-garage' : 'mdi-garage-open'" />
<div class="kiosk-device-text">
{{ capitalize(homeAssistantStore.garageState) }}
</div>
</LongPressButton>
<div
class="kiosk-house-alarm text-center pt-4"
v-if="homeAssistantStore?.houseAlarmState"
:class="homeAssistantStore.houseAlarmState === 'disarmed' ? 'normal' : 'warning'">
<v-icon
class="kiosk-device-icon"
icon="mdi-shield-home" />
<div class="kiosk-device-text">
{{ alarmState(homeAssistantStore.houseAlarmState) }}
</div>
</div>
</div>
<div class="kiosk-content">
<CalendarAgenda
class="kiosk-calendar"
days="10"
:refresh-interval="5 * 60 * 1000" />
<NationalDays class="kiosk-national-days" />
</div>
</v-container>
</template>
<style scoped>
.kiosk-container {
height: 100%;
padding: 0;
background-color: #212428;
color: #ebebeb;
display: grid;
grid-template-columns: 250px 1fr;
grid-template-rows: 1fr;
grid-auto-flow: row;
grid-template-areas: 'kiosk-sidebar kiosk-content';
}
.kiosk-row {
padding-top: 5px;
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
}
.kiosk-sidebar {
padding: 5px;
background-color: #121212;
grid-area: kiosk-sidebar;
display: grid;
grid-template-columns: repeat(2, 50%);
grid-template-rows: repeat(7, auto) 1fr;
grid-column-gap: 0px;
grid-row-gap: 0px;
grid-template-areas:
'kiosk-time kiosk-time'
'kiosk-date kiosk-date'
'kiosk-temperature kiosk-humidity'
'kiosk-wind kiosk-pressure'
'kiosk-generation kiosk-consumption'
'kiosk-washer kiosk-dryer'
'kiosk-garage-door kiosk-house-alarm';
}
.kiosk-content {
height: 100%;
max-height: calc(100vh - 30px);
padding: 10px;
gap: 10px;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(4, 25%);
grid-auto-flow: row;
grid-template-areas:
'kiosk-calendar kiosk-national-days kiosk-national-days'
'kiosk-calendar kiosk-national-days kiosk-national-days'
'kiosk-calendar kiosk-national-days kiosk-national-days'
'kiosk-calendar kiosk-national-days kiosk-national-days';
}
.kiosk-time {
font-size: 2.8rem;
grid-area: kiosk-time;
}
.kiosk-date {
font-size: 1.25rem;
grid-area: kiosk-date;
}
.kiosk-temperature {
font-size: 2.9rem;
grid-area: kiosk-temperature;
}
.kiosk-humidity {
font-size: 2.9rem;
grid-area: kiosk-humidity;
}
.kiosk-wind {
font-size: 2rem;
grid-area: kiosk-wind;
}
.kiosk-pressure {
font-size: 2rem;
grid-area: kiosk-pressure;
}
.kiosk-generation {
grid-area: kiosk-generation;
}
.kiosk-consumption {
grid-area: kiosk-consumption;
}
.kiosk-washer {
grid-area: kiosk-washer;
}
.kiosk-dryer {
grid-area: kiosk-dryer;
}
.kiosk-garage-door {
grid-area: kiosk-garage-door;
}
.kiosk-house-alarm {
grid-area: kiosk-house-alarm;
}
.kiosk-device-icon {
font-size: 2.5rem;
}
.kiosk-device-text {
font-size: 1.3rem;
}
.kiosk-calendar {
grid-area: kiosk-calendar;
}
.kiosk-national-days {
grid-area: kiosk-national-days;
}
.warning {
color: #d09f27;
}
.normal {
color: #208b20;
}
.display-item {
display: flex;
flex-direction: column;
}
.display-item-value {
line-height: calc(100% + 2px);
}
.display-item-header {
font-size: 10pt;
}
.display-item-label {
font-size: 10pt;
}
.temperature-wind-chill {
color: #4d4dff;
}
.temperature-heat-index {
color: #ff4d4d;
}
.out-of-date-reading {
color: #d09f27;
}
</style>

View File

@@ -0,0 +1,23 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import Environment from '@/environment';
import CalendarEntry from '@/models/calendar/calendar-entry';
import NationalDayEntry from '@/models/calendar/national-day';
export const useCalendarStore = defineStore('calendar', {
state: () => {
return {};
},
actions: {
async getUpcoming(days: number, includeHolidays: boolean): Promise<CalendarEntry[]> {
const response = await axios.get<CalendarEntry[]>(Environment.getUrlPrefix() + `:8081/api/calendar/calendar/upcoming?days=${days}&includeHolidays=${includeHolidays}`);
return response.data;
},
async getNationalDays(): Promise<NationalDayEntry[]> {
const response = await axios.get<NationalDayEntry[]>(Environment.getUrlPrefix() + `:8081/api/calendar/national-days/today?timezone=${Intl.DateTimeFormat().resolvedOptions().timeZone}`);
return response.data;
}
}
});

View File

@@ -0,0 +1,74 @@
import { defineStore } from 'pinia';
import { createConnection, subscribeEntities, createLongLivedTokenAuth, Connection, callService } from 'home-assistant-js-websocket';
import Environment from '@/environment';
export const useHomeAssistantStore = defineStore('home-assistant', {
state: () => {
return {
garageState: null as string | null,
houseAlarmState: null as string | null,
_connection: null as Connection | null
};
},
actions: {
async start() {
if (!Environment.getHomeAssistantUrl() || !Environment.getHomeAssistantToken()) {
return;
}
const garageDevice = Environment.getGarageDevice();
const alarmDevice = Environment.getAlarmDevice();
const auth = createLongLivedTokenAuth(Environment.getHomeAssistantUrl(), Environment.getHomeAssistantToken());
this._connection = await createConnection({ auth });
subscribeEntities(this._connection as Connection, (entities) => {
const garageEntity = entities[garageDevice];
if (garageEntity) {
this.$patch({ garageState: garageEntity.state });
}
const houseAlarmEntity = entities[alarmDevice];
if (houseAlarmEntity) {
this.$patch({ houseAlarmState: houseAlarmEntity.state });
}
});
setInterval(async () => await this._connection?.ping(), 5000);
},
async stop() {
this._connection?.close();
this._connection = null;
},
async toggleGarage() {
const garageDevice = Environment.getGarageDevice();
let action: string | null;
switch (this.garageState) {
case 'closed':
action = 'open_cover';
break;
case 'open':
action = 'close_cover';
break;
case 'closing':
case 'opening':
action = 'stop_cover';
break;
default:
action = null;
break;
}
if (!action) {
return;
}
callService(this._connection as Connection, 'cover', action, { entity_id: garageDevice });
}
}
});

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { dateReviver } from '@/dateReviver';
import axios from 'axios';
import Environment from '@/environment';
import PowerStatus from '@/models/power/power-status';
@@ -27,7 +28,7 @@ export const usePowerStore = defineStore('power', {
await this._connection.start();
this._connection.on('LatestSample', (message: string) => {
this.$patch({ current: JSON.parse(message) });
this.$patch({ current: JSON.parse(message, dateReviver) });
});
},
async stop() {

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { dateReviver } from '@/dateReviver';
import axios from 'axios';
import Environment from '@/environment';
import WeatherUpdate from '@/models/weather/weather-update';
@@ -32,7 +33,9 @@ export const useWeatherStore = defineStore('weather', {
await this._connection.start();
this._connection.on('LatestReading', (message: string) => {
this.$patch({ current: JSON.parse(message) });
const json: WeatherUpdate = JSON.parse(message, dateReviver);
this.$patch({ current: json });
});
},
async stop() {

View File

@@ -40,10 +40,12 @@ import type {
declare module 'vue-router/auto/routes' {
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/indoor': RouteRecordInfo<'/indoor', '/indoor', Record<never, never>, Record<never, never>>,
'/outdoor': RouteRecordInfo<'/outdoor', '/outdoor', Record<never, never>, Record<never, never>>,
'/power': RouteRecordInfo<'/power', '/power', Record<never, never>, Record<never, never>>,
'/summary': RouteRecordInfo<'/summary', '/summary', Record<never, never>, Record<never, never>>,
'//': RouteRecordInfo<'//', '/', Record<never, never>, Record<never, never>>,
'//indoor': RouteRecordInfo<'//indoor', '/indoor', Record<never, never>, Record<never, never>>,
'//outdoor': RouteRecordInfo<'//outdoor', '/outdoor', Record<never, never>, Record<never, never>>,
'//power': RouteRecordInfo<'//power', '/power', Record<never, never>, Record<never, never>>,
'//summary': RouteRecordInfo<'//summary', '/summary', Record<never, never>, Record<never, never>>,
'/kiosk': RouteRecordInfo<'/kiosk', '/kiosk', Record<never, never>, Record<never, never>>,
}
}

View File

@@ -14,14 +14,14 @@ export default defineConfig({
plugins: [
VueRouter(),
Vue({
template: { transformAssetUrls },
template: { transformAssetUrls }
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
Vuetify({
autoImport: true,
styles: {
configFile: 'src/styles/settings.scss',
},
configFile: 'src/styles/settings.scss'
}
}),
Components(),
ViteFonts({
@@ -29,20 +29,27 @@ export default defineConfig({
families: [
{
name: 'Roboto',
styles: 'wght@100;300;400;500;700;900',
},
],
},
}),
styles: 'wght@100;300;400;500;700;900'
}
]
}
})
],
define: { 'process.env': {} },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url))
},
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue']
},
server: {
port: 4200,
port: 4200
},
css: {
preprocessorOptions: {
sass: {
api: 'modern-compiler'
}
}
}
});