import { DeepPartial , flatMap } from "@zwave-js/shared" ; import DailyRotateFile from "@zwave-js/winston-daily-rotate-file" ; import { padStart } from "alcalzone-shared/strings" ; import type { Format , TransformableInfo , TransformFunction } from "logform" ; import * as path from "path" ; import { configs , MESSAGE } from "triple-beam" ; import winston , { Logger } from "winston" ; import type Transport from "winston-transport" ; import type { ConsoleTransportInstance } from "winston/lib/winston/transports" ; import type { ValueID } from "../values/ValueDB" ; import { colorizer } from "./Colorizer" ; const { combine , timestamp , label } = winston . format ; const loglevels = configs . npm . levels ; const isTTY = process . stdout . isTTY ; const isUnitTest = process . env . NODE_ENV === "test" ; export const timestampFormatShort = "HH:mm:ss.SSS" ; export const timestampPaddingShort = " " . repeat ( timestampFormatShort . length + 1 ) ; export const timestampPadding = " " . repeat ( new Date ( ) . toISOString ( ) . length + 1 ) ; const channelPadding = " " . repeat ( 7 ) ; export type DataDirection = "inbound" | "outbound" | "none" ; export function getDirectionPrefix ( direction ) { return direction === "inbound" ? "« " : direction === "outbound" ? "» " : "  " ; } /** The space the directional arrows, grouping brackets and padding occupies */ export const CONTROL_CHAR_WIDTH = 2 ; export const directionPrefixPadding = " " . repeat ( CONTROL_CHAR_WIDTH ) ; /**
 * The width of a log line in (visible) characters, excluding the timestamp and
 * label, but including the direction prefix
 */ export const LOG_WIDTH = 80 ; /** The width of the columns containing the timestamp and channel */ export const LOG_PREFIX_WIDTH = 20 ; export interface ZWaveLogInfo < TContext extends LogContext = LogContext > extends Omit < TransformableInfo , "message" > { direction : string ; /** Primary tags are printed before the message and must fit into the first line.
     * They don't have to be enclosed in square brackets */ primaryTags ? : string ; /** Secondary tags are right-aligned in the first line and printed in a dim color */ secondaryTags ? : string ; secondaryTagPadding ? : number ; multiline ? : boolean ; timestamp ? : string ; label ? : string ; message : string | string [ ] ; context : TContext ; } export interface LogContext < T extends string = string > { /** Which logger this log came from */ source : T ; /** An optional identifier to distinguish different log types from the same logger */ type ? : string ; } export type NodeLogContext = LogContext & { nodeId : number ; type : "node" ; } ; export type ValueLogContext = LogContext & ValueID & { nodeId : number ; type : "value" ; } ; export type MessageRecord = Record < string , string | number | boolean | null | undefined > ; export interface MessageOrCCLogEntry { tags : string [ ] ; message ? : MessageRecord ; } /** Returns the tag used to log node related messages */ export function getNodeTag ( nodeId ) { return "Node " + padStart ( nodeId . toString ( ) , 3 , "0" ) ; } export type ZWaveLogger < TContext extends LogContext = LogContext > = Omit < Logger , "log" > & { log : < T extends TContext > ( info : ZWaveLogInfo < T > ) => void ; } ; export class ZWaveLoggerBase < TContext extends LogContext = LogContext > { constructor ( loggers , logLabel ) { this . container = loggers ; this . logger = this . container . getLogger ( logLabel ) ; } public logger : ZWaveLogger < TContext > ; public container : ZWaveLogContainer ; } export interface LogConfig { enabled : boolean ; level : string | number ; transports : Transport [ ] ; logToFile : boolean ; nodeFilter ? : number [ ] ; filename : string ; forceConsole : boolean ; } function stringToNodeList ( nodes ? ) { if ( ! nodes ) return undefined ; return nodes . split ( "," ) . map ( ( n ) => parseInt ( n ) ) . filter ( ( n ) => ! Number . isNaN ( n ) ) ; } export class ZWaveLogContainer extends winston . Container { private fileTransport : DailyRotateFile | undefined ; private consoleTransport : ConsoleTransportInstance | undefined ; private loglevelVisibleCache = new Map < string , boolean > ( ) ; private logConfig : LogConfig & { level : string ; } = { enabled : true , level : getTransportLoglevel ( ) , logToFile : ! ! process . env . LOGTOFILE , nodeFilter : stringToNodeList ( process . env . LOG_NODES ) , transports : undefined as any , filename : require . main ? path . join ( path . dirname ( require . main . filename ) , `zwavejs_%DATE%.log` ) : path . join ( __dirname , "../../.." , `zwavejs_%DATE%.log` ) , forceConsole : false } ; constructor ( config = { } ) { super ( ) ; this . updateConfiguration ( config ) ; } public getLogger ( label ) { if ( ! this . has ( label ) ) { this . add ( label , { transports : this . getAllTransports ( ) , format : createLoggerFormat ( label ) , level : "silly" } ) ; } return this . get ( label ) as unknown as ZWaveLogger ; } public updateConfiguration ( config ) { const changedLoggingTarget = ( config . logToFile != undefined && config . logToFile !== this . logConfig . logToFile ) || ( config . forceConsole != undefined && config . forceConsole !== this . logConfig . forceConsole ) ; if ( typeof config . level === "number" ) { config . level = loglevelFromNumber ( config . level ) ; } const changedLogLevel = config . level != undefined && config . level !== this . logConfig . level ; if ( config . filename != undefined && ! config . filename . includes ( "%DATE%" ) ) { config . filename += "_%DATE%.log" ; } const changedFilename = config . filename != undefined && config . filename !== this . logConfig . filename ; this . logConfig = Object . assign ( this . logConfig , config ) ; if ( changedLogLevel ) { this . loglevelVisibleCache . clear ( ) ; } const recreateInternalTransports = ( this . fileTransport == undefined && this . consoleTransport == undefined ) || changedLoggingTarget || changedFilename ; if ( recreateInternalTransports ) { this . fileTransport ?. destroy ( ) ; this . fileTransport = undefined ; this . consoleTransport ?. destroy ( ) ; this . consoleTransport = undefined ; } if ( recreateInternalTransports || config . transports != undefined ) { this . loggers . forEach ( ( logger ) => logger . configure ( { transports : this . getAllTransports ( ) } ) ) ; } } public getConfiguration ( ) { return this . logConfig ; } /** Tests whether a log using the given loglevel will be logged */ public isLoglevelVisible ( loglevel ) { if ( ! this . fileTransport && ! this . consoleTransport && ( ! this . logConfig . transports || this . logConfig . transports . length === 0 ) ) { return false ; } if ( ! this . loglevelVisibleCache . has ( loglevel ) ) { this . loglevelVisibleCache . set ( loglevel , loglevel in loglevels && loglevels [ loglevel ] <= loglevels [ this . logConfig . level ] ) ; } return this . loglevelVisibleCache . get ( loglevel ) ! ; } public destroy ( ) { for ( const key in this . loggers ) { this . close ( key ) ; } this . fileTransport = undefined ; this . consoleTransport = undefined ; this . logConfig . transports = [ ] ; } private getAllTransports ( ) { return [ ... this . getInternalTransports ( ) , ... ( this . logConfig . transports ?? [ ] ) , ] ; } private getInternalTransports ( ) { const ret = [ ] ; if ( this . logConfig . enabled && this . logConfig . logToFile ) { if ( ! this . fileTransport ) { this . fileTransport = this . createFileTransport ( ) ; } ret . push ( this . fileTransport ) ; } else if ( ! isUnitTest && ( isTTY || this . logConfig . forceConsole ) ) { if ( ! this . consoleTransport ) { this . consoleTransport = this . createConsoleTransport ( ) ; } ret . push ( this . consoleTransport ) ; } return ret ; } private createConsoleTransport ( ) { return new winston . transports . Console ( { format : createDefaultTransportFormat ( isTTY || isUnitTest , isTTY ) , silent : this . isConsoleTransportSilent ( ) } ) ; } private isConsoleTransportSilent ( ) { return process . env . NODE_ENV === "test" || ! this . logConfig . enabled ; } private isFileTransportSilent ( ) { return ! this . logConfig . enabled ; } private createFileTransport ( ) { const ret = new DailyRotateFile ( { filename : this . logConfig . filename , datePattern : "YYYY-MM-DD" , createSymlink : true , symlinkName : path . basename ( this . logConfig . filename ) . replace ( `_%DATE%` , "_current" ) , zippedArchive : true , maxFiles : "7d" , format : createDefaultTransportFormat ( false , false ) , silent : this . isFileTransportSilent ( ) } ) ; ret . on ( "new" , ( newFilename ) => { console . log ( `Logging to file:
	${ newFilename }` ) ; } ) ; return ret ; } /**
     * Checks the log configuration whether logs should be written for a given node id
     */ public shouldLogNode ( nodeId ) { if ( ! this . logConfig . nodeFilter ) return true ; return this . logConfig . nodeFilter . includes ( nodeId ) ; } } function getTransportLoglevel ( ) { return process . env . LOGLEVEL ! in loglevels ? process . env . LOGLEVEL ! : "debug" ; } /** Performs a reverse lookup of the numeric loglevel */ function loglevelFromNumber ( numLevel ) { if ( numLevel == undefined ) return ; for ( const [ level , value ] of Object . entries ( loglevels ) ) { if ( value === numLevel ) return level ; } } /** Creates the common logger format for all loggers under a given channel */ export function createLoggerFormat ( channel ) { return combine ( label ( { label : channel } ) , timestamp ( ) ) ; } /** Prints a formatted and colorized log message */ export function createLogMessagePrinter ( shortTimestamps ) { return { transform : ( ( info ) => { const messageLines = messageToLines ( info . message ) ; if ( info . secondaryTagPadding == undefined ) info . secondaryTagPadding = - 1 ; let firstLine = [ info . primaryTags , messageLines [ 0 ] , info . secondaryTagPadding < 0 ? undefined : " " . repeat ( info . secondaryTagPadding ) , info . secondaryTagPadding === 0 && info . secondaryTags ? " " + info . secondaryTags : info . secondaryTags , ] . filter ( ( item ) => ! ! item ) . join ( " " ) ; firstLine = `${ info . timestamp } ${ info . label } ${ info . direction }${ firstLine }` ; const lines = [ firstLine ] ; if ( info . multiline ) { lines . push ( ... messageLines . slice ( 1 ) . map ( ( line ) => ( shortTimestamps ? timestampPaddingShort : timestampPadding ) + channelPadding + directionPrefixPadding + line ) ) ; } info [ MESSAGE as any ] = lines . join ( "\n" ) ; return info ; } ) as unknown as TransformFunction } ; } /** Formats the log message and calculates the necessary paddings */ export const logMessageFormatter = { transform : ( ( info ) => { const messageLines = messageToLines ( info . message ) ; const firstMessageLineLength = messageLines [ 0 ] . length ; info . multiline = messageLines . length > 1 || ! messageFitsIntoOneLine ( info , info . message . length ) ; if ( info . secondaryTags ) { info . secondaryTagPadding = Math . max ( - 1 , LOG_WIDTH - 1 - calculateFirstLineLength ( info , firstMessageLineLength ) ) ; } if ( info . multiline ) { const lines = [ ] ; let isFirstLine = true ; for ( let message of messageLines ) { while ( message . length ) { const cut = Math . min ( message . length , isFirstLine ? LOG_WIDTH - calculateFirstLineLength ( info , 0 ) - 1 : LOG_WIDTH - CONTROL_CHAR_WIDTH ) ; isFirstLine = false ; lines . push ( message . substr ( 0 , cut ) ) ; message = message . substr ( cut ) ; } } info . message = lines . join ( "\n" ) ; } return info ; } ) as unknown as TransformFunction } ; /** The common logger format for built-in transports */ export function createDefaultTransportFormat ( colorize , shortTimestamps ) { const formats = [ shortTimestamps ? timestamp ( { format : timestampFormatShort } ) : undefined , logMessageFormatter , colorize ? colorizer ( ) : undefined , createLogMessagePrinter ( shortTimestamps ) , ] . filter ( ( f ) : f is Format => ! ! f ) ; return combine ( ... formats ) ; } param info param firstMessageLineLength function calculateFirstLineLength ( info , firstMessageLineLength ) { return ( [ CONTROL_CHAR_WIDTH - 1 , firstMessageLineLength , ( info . primaryTags || "" ) . length , ( info . secondaryTags || "" ) . length , ] . filter ( ( len ) => len > 0 ) . reduce ( ( prev , val ) => prev + ( prev > 0 ? 1 : 0 ) + val ) ) ; } param info param messageLength export function messageFitsIntoOneLine ( info , messageLength ) { const totalLength = calculateFirstLineLength ( info , messageLength ) ; return totalLength <= LOG_WIDTH ; } export function messageToLines ( message ) { if ( typeof message === "string" ) { return message . split ( "\n" ) ; } else if ( message . length > 0 ) { return message ; } else { return [ "" ] ; } } /** Splits a message record into multiple lines and auto-aligns key-value pairs */ export function messageRecordToLines ( message ) { const entries = Object . entries ( message ) ; if ( ! entries . length ) return [ ] ; const maxKeyLength = Math . max ( ... entries . map ( ( [ key ] ) => key . length ) ) ; return flatMap ( entries , ( [ key , value ] ) => `${ key }:${ " " . repeat ( Math . max ( maxKeyLength - key . length + 1 , 1 ) ) }${ value }` . split ( "\n" ) . map ( ( line ) => line . trimRight ( ) ) ) ; } /** Wraps an array of strings in square brackets and joins them with spaces */ export function tagify ( tags ) { return tags . map ( ( pfx ) => `[${ pfx }]` ) . join ( " " ) ; } /** Unsilences the console transport of a logger and returns the original value */ export function unsilence ( logger ) { const consoleTransport = logger . transports . find ( ( t ) => ( t as any ) . name === "console" ) ; if ( consoleTransport ) { const ret = ! ! consoleTransport . silent ; consoleTransport . silent = false ; return ret ; } return false ; } /** Restores the console transport of a logger to its original silence state */ export function restoreSilence ( logger , original ) { const consoleTransport = logger . transports . find ( ( t ) => ( t as any ) . name === "console" ) ; if ( consoleTransport ) { consoleTransport . silent = original ; } }