From d180c4ff8c7164b358052e2d288754e64d23bdb6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 28 Sep 2025 15:40:32 +0000 Subject: [PATCH 1/4] feat: Add logging service and log model Introduces a new logging service that logs to both console and database. Includes a new Log model for storing log entries and adds a new logs command. Co-authored-by: dan --- cmd/hal/main.go | 1 + go.mod | 2 +- logging/service.go | 121 +++++++++++++++++++++++++++++++++ logging/service_test.go | 107 +++++++++++++++++++++++++++++ logging/test_logs.db-shm | Bin 0 -> 32768 bytes logging/test_logs.db-wal | Bin 0 -> 123632 bytes logging/test_prune_logs.db-shm | Bin 0 -> 32768 bytes logging/test_prune_logs.db-wal | Bin 0 -> 103032 bytes store/models.go | 8 +++ store/sqlite.go | 2 +- test.db-shm | Bin 0 -> 32768 bytes test.db-wal | Bin 0 -> 57712 bytes test_direct_logs.db-shm | Bin 0 -> 32768 bytes test_direct_logs.db-wal | Bin 0 -> 140112 bytes test_logs.db-shm | Bin 0 -> 32768 bytes test_logs.db-wal | Bin 0 -> 57712 bytes 16 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 logging/service.go create mode 100644 logging/service_test.go create mode 100644 logging/test_logs.db-shm create mode 100644 logging/test_logs.db-wal create mode 100644 logging/test_prune_logs.db-shm create mode 100644 logging/test_prune_logs.db-wal create mode 100644 test.db-shm create mode 100644 test.db-wal create mode 100644 test_direct_logs.db-shm create mode 100644 test_direct_logs.db-wal create mode 100644 test_logs.db-shm create mode 100644 test_logs.db-wal diff --git a/cmd/hal/main.go b/cmd/hal/main.go index 1d62280..8226a3a 100644 --- a/cmd/hal/main.go +++ b/cmd/hal/main.go @@ -22,4 +22,5 @@ func main() { func init() { rootCmd.AddCommand(commands.NewStatsCmd()) + rootCmd.AddCommand(commands.NewLogsCmd()) } \ No newline at end of file diff --git a/go.mod b/go.mod index 8cca60d..7123bbd 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/go-cmp v0.5.9 github.com/gorilla/websocket v1.5.3 github.com/nathan-osman/go-sunrise v1.1.0 + github.com/spf13/cobra v1.9.1 gopkg.in/yaml.v3 v3.0.1 gorm.io/gorm v1.25.12 gotest.tools/v3 v3.5.1 @@ -24,7 +25,6 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect golang.org/x/sys v0.7.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/logging/service.go b/logging/service.go new file mode 100644 index 0000000..573016a --- /dev/null +++ b/logging/service.go @@ -0,0 +1,121 @@ +package logging + +import ( + "log/slog" + "time" + + "github.com/dansimau/hal/store" + "gorm.io/gorm" +) + +// Service handles logging to both console and database +type Service struct { + db *gorm.DB + pruneInterval time.Duration // How often to prune old logs (default: daily) + retentionTime time.Duration // How long to keep logs (default: 1 month) + stopChan chan struct{} +} + +// NewService creates a new logging service +func NewService(db *gorm.DB) *Service { + return &Service{ + db: db, + pruneInterval: 24 * time.Hour, // Prune daily + retentionTime: 30 * 24 * time.Hour, // Keep 1 month of logs + stopChan: make(chan struct{}), + } +} + +// Start begins the log pruning goroutine +func (s *Service) Start() { + go s.pruneLogs() + slog.Info("Logging service started") +} + +// Stop stops the logging service +func (s *Service) Stop() { + close(s.stopChan) + slog.Info("Logging service stopped") +} + +// Info logs an info message to both console and database +func (s *Service) Info(msg string, entityID *string, args ...any) { + // Log to console using slog + if entityID != nil { + args = append([]any{"entity_id", *entityID}, args...) + } + slog.Info(msg, args...) + + // Log to database + s.logToDatabase(msg, entityID) +} + +// Error logs an error message to both console and database +func (s *Service) Error(msg string, entityID *string, args ...any) { + // Log to console using slog + if entityID != nil { + args = append([]any{"entity_id", *entityID}, args...) + } + slog.Error(msg, args...) + + // Log to database + s.logToDatabase(msg, entityID) +} + +// Debug logs a debug message to both console and database +func (s *Service) Debug(msg string, entityID *string, args ...any) { + // Log to console using slog + if entityID != nil { + args = append([]any{"entity_id", *entityID}, args...) + } + slog.Debug(msg, args...) + + // Log to database + s.logToDatabase(msg, entityID) +} + +// Warn logs a warning message to both console and database +func (s *Service) Warn(msg string, entityID *string, args ...any) { + // Log to console using slog + if entityID != nil { + args = append([]any{"entity_id", *entityID}, args...) + } + slog.Warn(msg, args...) + + // Log to database + s.logToDatabase(msg, entityID) +} + +// logToDatabase writes the log entry to the database +func (s *Service) logToDatabase(msg string, entityID *string) { + log := store.Log{ + Timestamp: time.Now(), + EntityID: entityID, + LogText: msg, + } + + if err := s.db.Create(&log).Error; err != nil { + slog.Error("Failed to write log to database", "error", err, "message", msg) + } +} + +// pruneLogs runs in a goroutine to periodically remove old logs +func (s *Service) pruneLogs() { + ticker := time.NewTicker(s.pruneInterval) + defer ticker.Stop() + + for { + select { + case <-s.stopChan: + return + case <-ticker.C: + cutoffTime := time.Now().Add(-s.retentionTime) + result := s.db.Where("timestamp < ?", cutoffTime).Delete(&store.Log{}) + if result.Error != nil { + slog.Error("Failed to prune old logs", "error", result.Error) + } else if result.RowsAffected > 0 { + slog.Info("Pruned old logs", "count", result.RowsAffected, "cutoff", cutoffTime) + } + } + } +} \ No newline at end of file diff --git a/logging/service_test.go b/logging/service_test.go new file mode 100644 index 0000000..4c0bec7 --- /dev/null +++ b/logging/service_test.go @@ -0,0 +1,107 @@ +package logging + +import ( + "os" + "testing" + "time" + + "github.com/dansimau/hal/store" +) + +func TestLoggingService(t *testing.T) { + // Create temporary database + tmpDB := "test_logs.db" + defer os.Remove(tmpDB) + + // Open database + db, err := store.Open(tmpDB) + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + // Create logging service + service := NewService(db) + + // Test logging with entity ID + entityID := "light.kitchen" + service.Info("Light turned on", &entityID) + service.Error("Failed to turn off light", &entityID) + + // Test logging without entity ID + service.Info("System started", nil) + service.Debug("Debug message", nil) + + // Verify logs were written to database + var logs []store.Log + if err := db.Find(&logs).Error; err != nil { + t.Fatalf("Failed to query logs: %v", err) + } + + if len(logs) != 4 { + t.Errorf("Expected 4 logs, got %d", len(logs)) + } + + // Check first log with entity ID + if logs[0].LogText != "Light turned on" { + t.Errorf("Expected log text 'Light turned on', got '%s'", logs[0].LogText) + } + if logs[0].EntityID == nil || *logs[0].EntityID != "light.kitchen" { + t.Errorf("Expected entity ID 'light.kitchen', got %v", logs[0].EntityID) + } + + // Check log without entity ID + if logs[2].LogText != "System started" { + t.Errorf("Expected log text 'System started', got '%s'", logs[2].LogText) + } + if logs[2].EntityID != nil { + t.Errorf("Expected entity ID to be nil, got %v", logs[2].EntityID) + } +} + +func TestLogPruning(t *testing.T) { + // Create temporary database + tmpDB := "test_prune_logs.db" + defer os.Remove(tmpDB) + + // Open database + db, err := store.Open(tmpDB) + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + // Create logging service with short retention for testing + service := &Service{ + db: db, + retentionTime: 1 * time.Second, // Very short retention for testing + } + + // Add some logs + service.Info("Old log", nil) + + // Wait for retention period to pass + time.Sleep(2 * time.Second) + + // Add a new log + service.Info("New log", nil) + + // Manually trigger pruning + cutoffTime := time.Now().Add(-service.retentionTime) + result := db.Where("timestamp < ?", cutoffTime).Delete(&store.Log{}) + if result.Error != nil { + t.Fatalf("Failed to prune logs: %v", result.Error) + } + + // Verify that only the new log remains + var logs []store.Log + if err := db.Find(&logs).Error; err != nil { + t.Fatalf("Failed to query logs: %v", err) + } + + if len(logs) != 1 { + t.Errorf("Expected 1 log after pruning, got %d", len(logs)) + } + + if logs[0].LogText != "New log" { + t.Errorf("Expected remaining log to be 'New log', got '%s'", logs[0].LogText) + } +} \ No newline at end of file diff --git a/logging/test_logs.db-shm b/logging/test_logs.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..3959fb1c32eb52d8ab3e3ed25c79f742d9be1d27 GIT binary patch literal 32768 zcmeI)O)dms5C-6y!3@JN{@eJufwjaPB;o)T7FKf)Yxl6Wa0C|+OD^C9!dKD}TQ(i} zo;Rsf&zGL=uIB*NydFnpRH_#-IVydr)b!-)zW@GqJ9~b3dAvM5AD`V^-_4#bnqS^u z9`%)c?#GX`$KS2IJNl*OO6B!@sfG4>(Dq^57u%lSnfK&*p8e%_`I`_RK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PF8{&?|c;hGlQWa*R9s1}`SijX>^O5dw7zm1+IRSV>rPL-7#?f^yMdh z^vcG+h*fIk`WIdrEvj3cO9+T9sfQ1v*1zT7v`gzrP&2h_~&K)e29s-`%NSD^iVtZLYpUc};)|xgeOXYj^4e#CEFLv9L>>n1@ z^`Zt}RMn49E|uh){dD_vP?1jPW3vZY|WgNj>_*3L95kLR|1Q0*~0R#|0009IL zxbp(?+JBwfFAz!ndjFs2F9zusxbwdV-Vp%=5I_I{1Q0*~0R#|00D-$8;2B5I{`PBs zdgkQSmQ}_P1S>wL%qOUN96{Z>Q{{04VwMR31Q0*~0R#|0009ILK)@Bq)`h}d&FWl( z6Dgk?J(en!+^5Qg^raQSQf2<`{$0sWo1z?lnN8E?-hQ)Ow7jfSJk?%cA?x6dJ$Rh#>Absn6b#3k)W^%i?B(VA;uTOiB3v%j-#WnF zyHRm;y|@#AMdboEmi@&&yA zhFC%X0R#|0009ILKmY**5J14E0-kz-{;3nc+5Xf&e!oh+KtqLv%q^_CUZB1)c~iZB zSiyt<0tg_000IagfB*uuC$PVMxoi66+`0hte=xcvfu8#sQ(e&g(KW$vJA zysL;WCIk>b009ILKmY**5I_I{1Q76-KuFb_8e|=hXqI!&LAPJvLTc))+MgIH_6y7_ z#=O58PHPZA009ILKmY**5I_I{1Q76tz%I2;8EW0q7HjL=8tdNLmN4U;JK7UF;vL)K z-CdpS9c{7gTVk;tvDjEHd)VG~BxfHyY~}mpf@#l8=dDq5A}=hQdkeVz0u5hFPHpWt z^ROI8pc>Z{@x_Dy0tg_000IagfB*srAbEgX2}H9z`2zPo`eNtzcZNSv z>=(GC7?=Ffa9V)?0tg_000IagfB*srAb`M{5_mu#YPgMB0%79G7l`Z(KJtfe{kyX` zj^G`|cxO$i(O3i!KmY**5I_I{1Q0*~0R+~7K)r|;P-VP;CK~I>7dZ0fch_yqzI8_S z3uwj>kuM;AFd={d0tg_000IagfB*srAb`OCDzHbBfdCJ!a1lSz-O<_JwoD+vBbnTo zhyt)DoKOIB;?N<}842+I2U?Z+0vi)A&nD-8xruxMpB_In3jqWWKmY**5I_I{1Q0*~0i~QT zpbHF7zQ7+B)7oFJ?E9DO7toD%CtpB3Fd={d0tg_000IagfB*srAb>zoV7o4I0+h(| z7wr@Aj(Du2y_gd)G+VH(akF4&rfqAqCh`S-ern<5g$I7HOO7MZjRnP6AYp(59{~gq zKmY**5I_I{1Q0*~fm#y?=!&XWkuRWKI)3GYPbbb7`vsnK@&$}1YpwNsUIY+8009IL zKmY**5I_I{1ZqrR<=g;I34*@zc3qXXG^jRT;Hi(Ai%sVq-9WxTjU7LHTm%q6009IL zKmY**5I_I{1a4cPoG%a%yLj>i7GD4I%xC7B7iGUdVEsQ!`2u2w2>}EUKmY**5I_I{ z1Q0*~0iOwU24qk`>k2^u?cH75I}^pAfL+#snXEZ(6$+WG(KuUG4xcYtZ%uOnl(WD_yH?B@@kw~M!r*XF5vUUu`x<=y>c_HuR8wZGh-XJ_QO zUxQdJ4j$#-u~BlfWX`urZuj$1KaTrxryujqxhL0i?WgzQx3WbLAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5)Gl|WPaOpHt4h`l&it9!W&fxj2Xek+1NodVfuND!z~Ap2$s0(AdFQ7<2cTutz1F*bqXBDNt~~GQP)|&stFJvK!5-N m0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBoLPXgbQIwtJ^ literal 0 HcmV?d00001 diff --git a/logging/test_prune_logs.db-wal b/logging/test_prune_logs.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..b8db884d2b51bc2d5f3a93506171e5596ca8bf30 GIT binary patch literal 103032 zcmeI*eQXnD9Ki9r>(*{_qZ+dmH)qZeVGV_~8*ieZ1C}^mHrR$ZjJAUvqhT9+x#5r~ zZoUN3Xo4>Z(U2Gug$!dbi~0w|KO`a&1;NA+i6$a0F@!&i48x1~Jl9^=ZX<&M8+6~x zy52p#yWV}eHM!@0zuz-+v$Io~^80v2xl@r-^Ol|6Th z&0ksWe-+K`Q`|oHfx%Xy4FLoYKmY**5I_I{1Q0*~fm=zSW4!9}jk(-$%~HW~JOt0`8EE-bAmT~b_8tuHF67W)?Kd%Xpk(=$I$Rl<$6Mtf^>y||_i z*0h=Ht>vJ7D8Ya|C$H<%qPFrnKh|GRmbUP|r_Dcmvt(nWD?!dNA%Fk^2q1s}0tg_0 z00IagfIxBz$UXvx9Y^qf<(cnZ&MD5!kmpLjKyrTKoD~8HAb2jXI-6j}MhEUS3sRS|&DIQc_kWqU%KrzKE)CXzB~e z*UQSQ^s?oP7yF|73-*QJ$Gx_proK(Y;@j`hw~-ZHzcJVs!7oGbMP7Y)ePd9bRV04j zo1}RJ)-}ByFEFR-g!<*~hmMj*Fbe;Qyd?q%AbQY^vnYn65x>Zs>T-zRuhTI2Z zf%MS^XEZXuv}{4i6S@e;A7s^#US6ihg7)*{g7LlN5k!U#7_)f<@*3Z1#|xC+fAZb^ zTQ4l6?x6jT$TI>6Ab=5w+t~e1HD=_SM z0cYl=xOf4vz=Qw-2q1s}0tg_000Ib%q(H|#PM0S;TkXuY3gp$VuWOYLS7K>A*>uC| z;teQqC-xqc!_`M>+r_Hnd5IS&uBg@*l~n809W;F*QEAbP)<5*c7iF}{HF~`Tn$t5s zPgShol4wClSv=8N4qAV7VWTw|u;*m#e_HfkO?0A8#Z+wgr7s#1&>ffIZYU7587qI&jK|4?5b@oR`B1Q0*~0R#|0 z009ILKmY**l2jleUZ5aQwExpCZP5_%0_kxUvTot9;|0<(OZvqNhz2GE5I_I{1Q0*~ z0R#}Z{RCE~4R%jIRySYXs87GbJ^k293HS8-TD`;zG}apJgAOvlcmyKnUjC`XsxtVZ zLoNi(jZM*P{J7UP)YP|$)^MX~)EO;tFAOO9Fs9-K~cIBNVUf}lo z8s>-*KmY**5I_I{1Q0*~0R#{jhJdUOpxFHa9mhWT@bZq1|76`k)qPG8KTHT9fB*sr zAb7IoxIdFArR>oIIOr2C)bH{HV7bq z00IagfB*srAb4Mf z?Dx<1`^)QVb!! z3ruuWre8(afM7_dFK}eV(YrQnyKp9wN6@Rddy^fC^Fsgu1Q0*~0R#|0009ILKwzv1 zq>1tZsw^*{i8D{AFEHicytlHynd_DP0-Ae=s4pOXF(H5e0tg_000IagfB*srAb`Lq z2oz{nC>Jn8mJ66Zb5>E|^hmjYGUFLpF5tTB3-o+{w7j=x)7vtSK$GJUTx3LoQTX6_ zO9T)=009ILKmY**5I_I{1a784ibGMg!Ria#vF_K8FYI$RNBRZ!i24HVJvZ|}*f#h9M&HF%865L5?47^?0@=4B1lknHrXeBFra*RU34t~RvQbV5v?-ANi9(=Ffqt9E zc4`74a7}?gvjW*dEd&S388Ta~-m+{!_kX3b@^ zW3HwX^odUf3JU(8Lwq1Ol*t}#KJkIXa~jCk%;oBripbU;;%(+<4tn=R)0aPm}~d!}GnMaOn* ztL)!K2FKF_*|e4&I6R!zOn=za`plUHE9Y2wBj=b}Ud%c6oTZzUlAK>XYUfSOu@)WO zbe2k%KVNpls@}rb_|V9}_>^`uJtg)n)=#AlM54*TURAM+d26wJq9Bgxja`w`7C%RD+PL-pyVu6g-6dZu=LMScKhCQ{009ILKmY**5I_I{1Q0-=ArPt~_~qrv zT;c46&JF4a+8Ud?bp@NQBWT?faq9?nWyK5=0tg_000IagfB*srAb`L&2#mExll%9p zQ*Jiii=daS6BVmCW35_j3nk@O-Pu>niEzBwGF6>ui6wh_)U&F)&pFFkuxHBNO>1Z$ zrZ+0X_A)?k7aeQXTF`<~`GJY-=uk#%Hj>U{MY>*O@I|VAuGEO+YnjokmYEnH*1i21 zjR=0=zLU8^MP%{)$7pQirt6oCMuJ~P@I_s{Jzq5BtE%PqQ%zb&;2zWad4U`K=FNk@ zeRqU9f^GOH@{kB1fB*srAb|ZiBqS{A^kN#lo%enw_UwUUW&ZH z!1$DQG(Dx!chJ#I(P`1~`XB1Sri^a8##HJ+B$^!TRTVc};x&Yn%@f^a!+q1)R@vzH z=VbOj?tRqcF@DU?3$(s|>dG6B-1{y41-9~wh{r+z0R#|0009ILKmY**5I|tt1VVX% zAD0t%pSk>8c7wcte@;O@Y1y}M(|Lhd$J?R2fLLTg009ILKmY**5I_I{1i}K-v1qct zOMSLpW-l+_*RFudig39UUt3@wUa3}HFUQQjKrwlWygp_@DEcM9b( ze%#LseEq}qhZD-}!}J#je+{vO00IagfB*srAbMQ{)ATd24aq9~t2H2t>`je5=&EGU(nI$*aZ>+;=ipsEAR!=vcGXLg0=y zO&@ktUO*n>xBGd4Uq6|AW#7fO%H##^ysu%NF#-r6fB*srAbRvZ|YtN A&Hw-a literal 0 HcmV?d00001 diff --git a/test_direct_logs.db-shm b/test_direct_logs.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..557ca87cc903306f1c49cc24feecd4da041900ba GIT binary patch literal 32768 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 HT!+9P(~&9^ literal 0 HcmV?d00001 diff --git a/test_logs.db-wal b/test_logs.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..3db2c955e707b47b636cbc5c4d4fe92702c2d89f GIT binary patch literal 57712 zcmeI*Z)h8J9LMoo+We`u>wHksSt|EPax~j03KIk|)_`Sg(se1?$-K*&TRBaWthtP~ zI!3!E!hA3h6u~DxD8dlY2jY{-d@vZ3DT34C6JhEjhoU~2J{b7>UG9>*Hc~9oy7qlg za`(&KC3!Uo+}-E<&7rf=mC*hl`a_|;Avu+Mz*5PpINmUi=hQ(ip+{T^c_MP5>xOMjDTW&WS4jnl8 zb5HM|p>S_1)SK$P+HNJr5I_I{1Q0*~0R#|0009IL_&*6O_bai);GnY7VHZx6OtWlT zwq@45+wjO_CY{fy`Sep`8P)KH4K-=Z%$o(W*Dw&u)~QLoDRwWC(iP;GO; zP8s%M)%50Twpi6&n3xoL={kbWJz=MgU{7AmFd={d0tg_000IagfB*srY=gi=XEZT1 zq)a>6csGJxGf&mc@{GA=F%n41uemc>E{Jfv*iu`b>WC!<29$G(v(Guxp0{Rd?oDT4 zABH}`$=CIU!#to@zVjjwvIp^<43)`K(~_o{qy7FpV4>F`yg_Q00IagfB*sr zAbyij4f0Br*_!-|1zY3Eq5rHe-ntfdV(+^Ax%x(LyJnX>jl4j5 za#}r{nO5mLXr~O(Y0-B3AEx|G8J%{GnszW8O^ggGAtzkoHiVST6P;zb009ILKmY**5V-dQj>X$u({J|8mlx{0{^6Q_bEUvF z{nn^PUZ7kw7ux>F0KZ2dYVPG*rP-As<^JS?ZZB5dYJA^)rwgUJ7`4i_Icv`Q?pW9K zVOQk^ Date: Sun, 28 Sep 2025 15:57:30 +0000 Subject: [PATCH 2/4] Ignore SQLite database files and related WAL/SHM files Co-authored-by: dan --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index 6922ba8..e17153b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,11 @@ cover.out # Databases sqlite.db* test-results.json + +# SQLite database files and related WAL/SHM files +*.db +*.db-wal +*.db-shm +**/*.db +**/*.db-wal +**/*.db-shm From a0c29030d09cf969968c141860cd40e2cfc65984 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 28 Sep 2025 16:10:57 +0000 Subject: [PATCH 3/4] feat: Add logs command to display HAL log entries Co-authored-by: dan --- .gitignore | 2 +- cmd/hal/commands/logs.go | 161 +++++++++++++++++++++++++++++++++ logging/test_logs.db-shm | Bin 32768 -> 0 bytes logging/test_logs.db-wal | Bin 123632 -> 0 bytes logging/test_prune_logs.db-shm | Bin 32768 -> 0 bytes logging/test_prune_logs.db-wal | Bin 103032 -> 0 bytes test.db-shm | Bin 32768 -> 0 bytes test.db-wal | Bin 57712 -> 0 bytes test_direct_logs.db-shm | Bin 32768 -> 0 bytes test_direct_logs.db-wal | Bin 140112 -> 0 bytes test_logs.db-shm | Bin 32768 -> 0 bytes test_logs.db-wal | Bin 57712 -> 0 bytes 12 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 cmd/hal/commands/logs.go delete mode 100644 logging/test_logs.db-shm delete mode 100644 logging/test_logs.db-wal delete mode 100644 logging/test_prune_logs.db-shm delete mode 100644 logging/test_prune_logs.db-wal delete mode 100644 test.db-shm delete mode 100644 test.db-wal delete mode 100644 test_direct_logs.db-shm delete mode 100644 test_direct_logs.db-wal delete mode 100644 test_logs.db-shm delete mode 100644 test_logs.db-wal diff --git a/.gitignore b/.gitignore index e17153b..f0d815e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -hal +/hal # Test coverage cover.out diff --git a/cmd/hal/commands/logs.go b/cmd/hal/commands/logs.go new file mode 100644 index 0000000..d933079 --- /dev/null +++ b/cmd/hal/commands/logs.go @@ -0,0 +1,161 @@ +package commands + +import ( + "fmt" + "os" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/dansimau/hal/store" + "github.com/spf13/cobra" +) + +// NewLogsCmd creates the logs command +func NewLogsCmd() *cobra.Command { + var dbPath string + var fromTime string + var toTime string + var lastDuration string + var entityID string + + cmd := &cobra.Command{ + Use: "logs", + Aliases: []string{"log"}, + Short: "Display HAL log entries", + Long: `Display log entries from the HAL automation system. +Shows logs in chronological order with optional filtering by time range or entity.`, + Example: ` hal logs # Show all logs in chronological order + hal logs --from "2024-01-01" # Show logs from a specific date + hal logs --to "2024-01-31" # Show logs up to a specific date + hal logs --from "2024-01-01" --to "2024-01-31" # Show logs in date range + hal logs --last 5m # Show logs from last 5 minutes + hal logs --last 1h # Show logs from last 1 hour + hal logs --last 1d # Show logs from last 1 day + hal logs --entity-id "light.kitchen" # Show logs for specific entity + hal logs --db custom.db # Use custom database`, + RunE: func(cmd *cobra.Command, args []string) error { + return runLogsCommand(dbPath, fromTime, toTime, lastDuration, entityID) + }, + } + + cmd.Flags().StringVar(&dbPath, "db", "sqlite.db", "Database file path") + cmd.Flags().StringVar(&fromTime, "from", "", "Start time for filtering logs (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)") + cmd.Flags().StringVar(&toTime, "to", "", "End time for filtering logs (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)") + cmd.Flags().StringVar(&lastDuration, "last", "", "Show logs from last duration (e.g., 5m, 1h, 2d)") + cmd.Flags().StringVar(&entityID, "entity-id", "", "Filter logs by entity ID") + + return cmd +} + +func runLogsCommand(dbPath, fromTime, toTime, lastDuration, entityID string) error { + // Open database connection + db, err := store.Open(dbPath) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + + // Build query with filters + query := db.Model(&store.Log{}) + + // Apply time filters + if lastDuration != "" { + duration, err := parseDuration(lastDuration) + if err != nil { + return fmt.Errorf("invalid duration format: %w", err) + } + since := time.Now().Add(-duration) + query = query.Where("timestamp > ?", since) + } else { + if fromTime != "" { + from, err := parseTime(fromTime) + if err != nil { + return fmt.Errorf("invalid from time format: %w", err) + } + query = query.Where("timestamp >= ?", from) + } + if toTime != "" { + to, err := parseTime(toTime) + if err != nil { + return fmt.Errorf("invalid to time format: %w", err) + } + query = query.Where("timestamp <= ?", to) + } + } + + // Apply entity filter + if entityID != "" { + query = query.Where("entity_id = ?", entityID) + } + + // Execute query and get results + var logs []store.Log + if err := query.Order("timestamp ASC").Find(&logs).Error; err != nil { + return fmt.Errorf("failed to query logs: %w", err) + } + + // Print results + return printLogs(logs) +} + +func parseDuration(durationStr string) (time.Duration, error) { + // Handle common duration formats like 5m, 1h, 2d + if strings.HasSuffix(durationStr, "d") { + days, err := strconv.Atoi(strings.TrimSuffix(durationStr, "d")) + if err != nil { + return 0, err + } + return time.Duration(days) * 24 * time.Hour, nil + } + + // For other formats (m, h, s), use standard time.ParseDuration + return time.ParseDuration(durationStr) +} + +func parseTime(timeStr string) (time.Time, error) { + // Try different time formats + formats := []string{ + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", + } + + for _, format := range formats { + if t, err := time.Parse(format, timeStr); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse time: %s (expected formats: YYYY-MM-DD, YYYY-MM-DD HH:MM, or YYYY-MM-DD HH:MM:SS)", timeStr) +} + +func printLogs(logs []store.Log) error { + if len(logs) == 0 { + fmt.Println("No logs found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + defer w.Flush() + + // Print header + fmt.Fprintf(w, "Timestamp\tEntity ID\tMessage\n") + fmt.Fprintf(w, "---------\t---------\t-------\n") + + // Print data rows + for _, log := range logs { + entityIDStr := "" + if log.EntityID != nil { + entityIDStr = *log.EntityID + } + + fmt.Fprintf(w, "%s\t%s\t%s\n", + log.Timestamp.Format("2006-01-02 15:04:05"), + entityIDStr, + log.LogText, + ) + } + + return nil +} \ No newline at end of file diff --git a/logging/test_logs.db-shm b/logging/test_logs.db-shm deleted file mode 100644 index 3959fb1c32eb52d8ab3e3ed25c79f742d9be1d27..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 diff --git a/logging/test_prune_logs.db-wal b/logging/test_prune_logs.db-wal deleted file mode 100644 index b8db884d2b51bc2d5f3a93506171e5596ca8bf30..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 103032 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 A&Hw-a diff --git a/test_direct_logs.db-shm b/test_direct_logs.db-shm deleted file mode 100644 index 557ca87cc903306f1c49cc24feecd4da041900ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 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 HT!+9P(~&9^ diff --git a/test_logs.db-wal b/test_logs.db-wal deleted file mode 100644 index 3db2c955e707b47b636cbc5c4d4fe92702c2d89f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57712 zcmeI*Z)h8J9LMoo+We`u>wHksSt|EPax~j03KIk|)_`Sg(se1?$-K*&TRBaWthtP~ zI!3!E!hA3h6u~DxD8dlY2jY{-d@vZ3DT34C6JhEjhoU~2J{b7>UG9>*Hc~9oy7qlg za`(&KC3!Uo+}-E<&7rf=mC*hl`a_|;Avu+Mz*5PpINmUi=hQ(ip+{T^c_MP5>xOMjDTW&WS4jnl8 zb5HM|p>S_1)SK$P+HNJr5I_I{1Q0*~0R#|0009IL_&*6O_bai);GnY7VHZx6OtWlT zwq@45+wjO_CY{fy`Sep`8P)KH4K-=Z%$o(W*Dw&u)~QLoDRwWC(iP;GO; zP8s%M)%50Twpi6&n3xoL={kbWJz=MgU{7AmFd={d0tg_000IagfB*srY=gi=XEZT1 zq)a>6csGJxGf&mc@{GA=F%n41uemc>E{Jfv*iu`b>WC!<29$G(v(Guxp0{Rd?oDT4 zABH}`$=CIU!#to@zVjjwvIp^<43)`K(~_o{qy7FpV4>F`yg_Q00IagfB*sr zAbyij4f0Br*_!-|1zY3Eq5rHe-ntfdV(+^Ax%x(LyJnX>jl4j5 za#}r{nO5mLXr~O(Y0-B3AEx|G8J%{GnszW8O^ggGAtzkoHiVST6P;zb009ILKmY**5V-dQj>X$u({J|8mlx{0{^6Q_bEUvF z{nn^PUZ7kw7ux>F0KZ2dYVPG*rP-As<^JS?ZZB5dYJA^)rwgUJ7`4i_Icv`Q?pW9K zVOQk^ Date: Sun, 28 Sep 2025 16:31:55 +0000 Subject: [PATCH 4/4] Refactor: Use internal logging service and simplify logs command Co-authored-by: dan --- cmd/hal/commands/logs.go | 28 ++++++++------------------- connection.go | 41 +++++++++++++++++++++------------------- entity_button.go | 4 ++-- entity_input_boolean.go | 18 ++++++++++++------ entity_light.go | 18 ++++++++++++------ store/models.go | 2 +- 6 files changed, 57 insertions(+), 54 deletions(-) diff --git a/cmd/hal/commands/logs.go b/cmd/hal/commands/logs.go index d933079..09da939 100644 --- a/cmd/hal/commands/logs.go +++ b/cmd/hal/commands/logs.go @@ -2,10 +2,8 @@ package commands import ( "fmt" - "os" "strconv" "strings" - "text/tabwriter" "time" "github.com/dansimau/hal/store" @@ -14,7 +12,6 @@ import ( // NewLogsCmd creates the logs command func NewLogsCmd() *cobra.Command { - var dbPath string var fromTime string var toTime string var lastDuration string @@ -33,14 +30,12 @@ Shows logs in chronological order with optional filtering by time range or entit hal logs --last 5m # Show logs from last 5 minutes hal logs --last 1h # Show logs from last 1 hour hal logs --last 1d # Show logs from last 1 day - hal logs --entity-id "light.kitchen" # Show logs for specific entity - hal logs --db custom.db # Use custom database`, + hal logs --entity-id "light.kitchen" # Show logs for specific entity`, RunE: func(cmd *cobra.Command, args []string) error { - return runLogsCommand(dbPath, fromTime, toTime, lastDuration, entityID) + return runLogsCommand(fromTime, toTime, lastDuration, entityID) }, } - cmd.Flags().StringVar(&dbPath, "db", "sqlite.db", "Database file path") cmd.Flags().StringVar(&fromTime, "from", "", "Start time for filtering logs (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)") cmd.Flags().StringVar(&toTime, "to", "", "End time for filtering logs (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)") cmd.Flags().StringVar(&lastDuration, "last", "", "Show logs from last duration (e.g., 5m, 1h, 2d)") @@ -49,9 +44,9 @@ Shows logs in chronological order with optional filtering by time range or entit return cmd } -func runLogsCommand(dbPath, fromTime, toTime, lastDuration, entityID string) error { - // Open database connection - db, err := store.Open(dbPath) +func runLogsCommand(fromTime, toTime, lastDuration, entityID string) error { + // Open database connection using default path + db, err := store.Open("sqlite.db") if err != nil { return fmt.Errorf("failed to open database: %w", err) } @@ -136,21 +131,14 @@ func printLogs(logs []store.Log) error { return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - - // Print header - fmt.Fprintf(w, "Timestamp\tEntity ID\tMessage\n") - fmt.Fprintf(w, "---------\t---------\t-------\n") - - // Print data rows + // Print logs without header to look like a log file for _, log := range logs { entityIDStr := "" if log.EntityID != nil { - entityIDStr = *log.EntityID + entityIDStr = fmt.Sprintf(" [%s]", *log.EntityID) } - fmt.Fprintf(w, "%s\t%s\t%s\n", + fmt.Printf("%s%s %s\n", log.Timestamp.Format("2006-01-02 15:04:05"), entityIDStr, log.LogText, diff --git a/connection.go b/connection.go index 88c5293..d27b264 100644 --- a/connection.go +++ b/connection.go @@ -2,12 +2,12 @@ package hal import ( "fmt" - "log/slog" "os" "sync" "time" "github.com/dansimau/hal/hassws" + "github.com/dansimau/hal/logging" "github.com/dansimau/hal/metrics" "github.com/dansimau/hal/perf" "github.com/dansimau/hal/store" @@ -29,8 +29,9 @@ type Connection struct { // Lock to serialize state updates and ensure automations fire in order. mutex sync.RWMutex - homeAssistant *hassws.Client - metricsService *metrics.Service + homeAssistant *hassws.Client + metricsService *metrics.Service + loggingService *logging.Service *SunTimes } @@ -58,10 +59,11 @@ func NewConnection(cfg Config) *Connection { }) return &Connection{ - config: cfg, - db: db, - homeAssistant: api, - metricsService: metrics.NewService(db), + config: cfg, + db: db, + homeAssistant: api, + metricsService: metrics.NewService(db), + loggingService: logging.NewService(db), automations: make(map[string][]Automation), entities: make(map[string]EntityInterface), @@ -82,7 +84,7 @@ func (h *Connection) FindEntities(v any) { // RegisterAutomations registers automations and binds them to the relevant entities. func (h *Connection) RegisterAutomations(automations ...Automation) { for _, automation := range automations { - slog.Info("Registering automation", "Name", automation.Name()) + h.loggingService.Info("Registering automation", nil, "Name", automation.Name()) for _, entity := range automation.Entities() { h.automations[entity.GetID()] = append(h.automations[entity.GetID()], automation) @@ -93,7 +95,8 @@ func (h *Connection) RegisterAutomations(automations ...Automation) { // RegisterEntities registers entities and binds them to the connection. func (h *Connection) RegisterEntities(entities ...EntityInterface) { for _, entity := range entities { - slog.Info("Registering entity", "EntityID", entity.GetID()) + entityID := entity.GetID() + h.loggingService.Info("Registering entity", &entityID, "EntityID", entityID) entity.BindConnection(h) h.entities[entity.GetID()] = entity @@ -106,8 +109,9 @@ func (h *Connection) RegisterEntities(entities ...EntityInterface) { // Start connects to the Home Assistant websocket and starts listening for events. func (h *Connection) Start() error { - // Start metrics service + // Start services h.metricsService.Start() + h.loggingService.Start() if err := h.homeAssistant.Connect(); err != nil { return err @@ -126,12 +130,13 @@ func (h *Connection) Start() error { func (h *Connection) Close() { h.metricsService.Stop() + h.loggingService.Stop() h.homeAssistant.Close() } func (h *Connection) syncStates() error { defer perf.Timer(func(timeTaken time.Duration) { - slog.Info("Initial state sync complete", "duration", timeTaken) + h.loggingService.Info("Initial state sync complete", nil, "duration", timeTaken) })() states, err := h.homeAssistant.GetStates() @@ -145,7 +150,7 @@ func (h *Connection) syncStates() error { continue } - slog.Debug("Setting initial state", "EntityID", state.EntityID, "State", state) + h.loggingService.Debug("Setting initial state", &state.EntityID, "EntityID", state.EntityID, "State", state) entity.SetState(state) } @@ -157,7 +162,7 @@ func (h *Connection) syncStates() error { // entity and fire any automations listening for state changes to this entity. func (h *Connection) StateChangeEvent(event hassws.EventMessage) { defer perf.Timer(func(timeTaken time.Duration) { - slog.Debug("Tick processing time", "duration", timeTaken) + h.loggingService.Debug("Tick processing time", &event.Event.EventData.EntityID, "duration", timeTaken) // Record tick processing time metric h.metricsService.RecordTimer(store.MetricTypeTickProcessingTime, timeTaken, event.Event.EventData.EntityID, "") })() @@ -167,14 +172,12 @@ func (h *Connection) StateChangeEvent(event hassws.EventMessage) { entity, ok := h.entities[event.Event.EventData.EntityID] if !ok { - slog.Debug("Entity not registered", "EntityID", event.Event.EventData.EntityID) + h.loggingService.Debug("Entity not registered", &event.Event.EventData.EntityID, "EntityID", event.Event.EventData.EntityID) return } - slog.Debug("State changed for", - "EntityID", event.Event.EventData.EntityID, - ) + h.loggingService.Debug("State changed for", &event.Event.EventData.EntityID, "EntityID", event.Event.EventData.EntityID) fmt.Fprintf(os.Stderr, "Diff:\n%s\n", cmp.Diff(event.Event.EventData.OldState, event.Event.EventData.NewState)) @@ -193,14 +196,14 @@ func (h *Connection) StateChangeEvent(event hassws.EventMessage) { // Prevent loops by not running automations that originate from hal if event.Event.Context.UserID == h.config.HomeAssistant.UserID { - slog.Debug("Skipping automation from own action", "EntityID", event.Event.EventData.EntityID) + h.loggingService.Debug("Skipping automation from own action", &event.Event.EventData.EntityID, "EntityID", event.Event.EventData.EntityID) return } // Dispatch automations for _, automation := range h.automations[event.Event.EventData.EntityID] { - slog.Info("Running automation", "name", automation.Name()) + h.loggingService.Info("Running automation", &event.Event.EventData.EntityID, "name", automation.Name()) // Record automation triggered metric h.metricsService.RecordCounter(store.MetricTypeAutomationTriggered, event.Event.EventData.EntityID, automation.Name()) automation.Action(entity) diff --git a/entity_button.go b/entity_button.go index ea94cc2..414d671 100644 --- a/entity_button.go +++ b/entity_button.go @@ -1,7 +1,6 @@ package hal import ( - "log/slog" "time" ) @@ -39,7 +38,8 @@ func (b *Button) Action(_ EntityInterface) { b.pressedTimes = 1 } - slog.Info("Button pressed", "entity", b.GetID(), "times", b.pressedTimes) + entityID := b.GetID() + b.connection.loggingService.Info("Button pressed", &entityID, "entity", entityID, "times", b.pressedTimes) b.lastPressed = time.Now() } diff --git a/entity_input_boolean.go b/entity_input_boolean.go index 532d6cf..83d6ea6 100644 --- a/entity_input_boolean.go +++ b/entity_input_boolean.go @@ -24,13 +24,15 @@ func (s *InputBoolean) IsOn() bool { } func (s *InputBoolean) TurnOn(attributes ...map[string]any) error { + entityID := s.GetID() if s.connection == nil { - slog.Error("InputBoolean not registered", "entity", s.GetID()) + // Use slog directly when connection is nil + slog.Error("InputBoolean not registered", "entity", entityID) return ErrEntityNotRegistered } - slog.Debug("Turning on virtual switch", "entity", s.GetID()) + s.connection.loggingService.Debug("Turning on virtual switch", &entityID, "entity", entityID) data := map[string]any{ "entity_id": []string{s.GetID()}, @@ -49,20 +51,23 @@ func (s *InputBoolean) TurnOn(attributes ...map[string]any) error { Data: data, }) if err != nil { - slog.Error("Error turning on virtual switch", "entity", s.GetID(), "error", err) + entityID := s.GetID() + s.connection.loggingService.Error("Error turning on virtual switch", &entityID, "entity", entityID, "error", err) } return err } func (s *InputBoolean) TurnOff() error { + entityID := s.GetID() if s.connection == nil { - slog.Error("InputBoolean not registered", "entity", s.GetID()) + // Use slog directly when connection is nil + slog.Error("InputBoolean not registered", "entity", entityID) return ErrEntityNotRegistered } - slog.Info("Turning off virtual switch", "entity", s.GetID()) + s.connection.loggingService.Info("Turning off virtual switch", &entityID, "entity", entityID) _, err := s.connection.CallService(hassws.CallServiceRequest{ Type: hassws.MessageTypeCallService, @@ -73,7 +78,8 @@ func (s *InputBoolean) TurnOff() error { }, }) if err != nil { - slog.Error("Error turning off virtual switch", "entity", s.GetID(), "error", err) + entityID := s.GetID() + s.connection.loggingService.Error("Error turning off virtual switch", &entityID, "entity", entityID, "error", err) } return err diff --git a/entity_light.go b/entity_light.go index 2bae032..ec95c42 100644 --- a/entity_light.go +++ b/entity_light.go @@ -39,13 +39,15 @@ func (l *Light) IsOn() bool { } func (l *Light) TurnOn(attributes ...map[string]any) error { + entityID := l.GetID() if l.connection == nil { - slog.Error("Light not registered", "entity", l.GetID()) + // Use slog directly when connection is nil + slog.Error("Light not registered", "entity", entityID) return ErrEntityNotRegistered } - slog.Debug("Turning on light", "entity", l.GetID()) + l.connection.loggingService.Debug("Turning on light", &entityID, "entity", entityID) data := map[string]any{ "entity_id": []string{l.GetID()}, @@ -64,7 +66,8 @@ func (l *Light) TurnOn(attributes ...map[string]any) error { Data: data, }) if err != nil { - slog.Error("Error turning on light", "entity", l.GetID(), "error", err) + entityID := l.GetID() + l.connection.loggingService.Error("Error turning on light", &entityID, "entity", entityID, "error", err) return err } @@ -73,13 +76,15 @@ func (l *Light) TurnOn(attributes ...map[string]any) error { } func (l *Light) TurnOff() error { + entityID := l.GetID() if l.connection == nil { - slog.Error("Light not registered", "entity", l.GetID()) + // Use slog directly when connection is nil + slog.Error("Light not registered", "entity", entityID) return ErrEntityNotRegistered } - slog.Info("Turning off light", "entity", l.GetID()) + l.connection.loggingService.Info("Turning off light", &entityID, "entity", entityID) data := map[string]any{ "entity_id": []string{l.GetID()}, @@ -92,7 +97,8 @@ func (l *Light) TurnOff() error { Data: data, }) if err != nil { - slog.Error("Error turning off light", "entity", l.GetID(), "error", err) + entityID := l.GetID() + l.connection.loggingService.Error("Error turning off light", &entityID, "entity", entityID, "error", err) return err } diff --git a/store/models.go b/store/models.go index 84fa722..6868700 100644 --- a/store/models.go +++ b/store/models.go @@ -42,6 +42,6 @@ type Metric struct { type Log struct { ID uint `gorm:"primaryKey;autoIncrement"` Timestamp time.Time `gorm:"index;not null"` - EntityID *string `gorm:"index;size:100"` // Optional: which entity this log relates to + EntityID *string `gorm:"index;size:255"` // Optional: which entity this log relates to LogText string `gorm:"not null;type:text"` }