mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-10 20:56:14 +00:00
~30 remaining go/log-injection alerts share a common pattern: other
positional args in a log call are wrapped in sanitizeLog() but the
trailing 'err' value (via "%v") is not. CodeQL traces taint through
error chains back to the log.Printf call site itself.
Add sanitizeErr(err error) string to every affected package's
logutil.go (strips newlines from err.Error(), returns "<nil>" when
nil). Three packages had no logutil.go yet; new files added for
cmd/soundtouch-cli, cmd/websocket-demo, and examples.
Call-site changes (replace "%v, err" with "%s, sanitizeErr(err)" and
wrap any other unsanitised args in sanitizeLog):
pkg/client:
- websocket.go:42 DefaultLogger.Printf now pre-formats and sanitises
the entire message (all variadic args sanitised)
- websocket.go:445 err → sanitizeErr(err)
pkg/service/handlers:
- handlers_account_mgmt.go:44 err
- handlers_bmx_tunein.go:324,336 err (stationID already sanitised)
- handlers_marge.go:288,510 err (deviceID/account already done)
- handlers_mgmt.go:409,436,720 err
- handlers_setup.go:1345 session + err
- server.go:500 bind
- server.go:504,863,944,1029, err (deviceIP/accountID already done)
1164,1174
pkg/service/marge:
- marge.go:1469,1923 saveErr / err
pkg/service/setup:
- setup.go:1417,2316,2462 fmt.Printf — deviceIP / hostsContent / ip
pkg/service/stockholm:
- proxy.go:117 effectiveTarget.String() + err
pkg/service/zeroconf:
- zeroconf.go:312 err
pkg/service/proxy:
- recorder.go:403 err (task.path already sanitised)
pkg/service/datastore:
- datastore.go:940 werr (device already sanitised)
pkg/discovery:
- dns.go:72 strings.Join(derived)
- dns.go:503 d.upstreamDNS (fmt.Sprint of []string)
cmd/soundtouch-cli:
- cmd_events.go:571 VerboseLogger.Printf — pre-format + sanitise
- common.go:335 PrintError message
cmd/websocket-demo:
- main.go:576 VerboseLogger.Printf — pre-format + sanitise
examples:
- recording-filename-demo.go:79 err
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
579 lines
14 KiB
Go
579 lines
14 KiB
Go
// Package main provides the soundtouch-cli events command for WebSocket event monitoring.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/client"
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
// eventSubscribe handles the events subscribe command
|
|
func eventSubscribe(c *cli.Context) error {
|
|
clientConfig := GetClientConfig(c)
|
|
|
|
// Parse filters
|
|
filterStr := c.String("filter")
|
|
filters := parseEventFilters(filterStr)
|
|
|
|
debugMode, err := parseDebugMode(c.String("debug"))
|
|
if err != nil {
|
|
PrintError(err.Error())
|
|
return err
|
|
}
|
|
|
|
// Parse duration
|
|
duration := c.Duration("duration")
|
|
verbose := c.Bool("verbose")
|
|
reconnect := !c.Bool("no-reconnect")
|
|
|
|
PrintDeviceHeader("Starting WebSocket event monitoring", clientConfig.Host, clientConfig.Port)
|
|
|
|
// Create SoundTouch client
|
|
soundTouchClient, err := CreateSoundTouchClient(clientConfig)
|
|
if err != nil {
|
|
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
|
return err
|
|
}
|
|
|
|
// Test basic connectivity
|
|
fmt.Println("Testing device connectivity...")
|
|
|
|
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
|
if err != nil {
|
|
PrintError(fmt.Sprintf("Failed to connect to device: %v", err))
|
|
return err
|
|
}
|
|
|
|
macAddress := ""
|
|
if len(deviceInfo.NetworkInfo) > 0 {
|
|
macAddress = deviceInfo.NetworkInfo[0].MacAddress
|
|
}
|
|
|
|
fmt.Printf("✅ Connected to: %s (Type: %s, MAC: %s)\n",
|
|
deviceInfo.Name, deviceInfo.Type, macAddress)
|
|
|
|
// Create WebSocket client
|
|
wsClient := setupWebSocketClient(soundTouchClient, reconnect, verbose)
|
|
|
|
// Set up event handlers
|
|
setupEventHandlers(wsClient, filters, verbose)
|
|
|
|
if debugMode != debugOff {
|
|
installDebugHook(wsClient, debugMode)
|
|
}
|
|
|
|
// Connect to WebSocket
|
|
fmt.Println("🔌 Connecting to WebSocket...")
|
|
|
|
err = wsClient.Connect()
|
|
if err != nil {
|
|
PrintError(fmt.Sprintf("Failed to connect to WebSocket: %v", err))
|
|
return err
|
|
}
|
|
|
|
fmt.Println("✅ Connected! Listening for events...")
|
|
|
|
if len(filters) > 0 {
|
|
fmt.Printf("📋 Filtering events: %s\n", strings.Join(getFilterKeys(filters), ", "))
|
|
}
|
|
|
|
if duration > 0 {
|
|
fmt.Printf("⏰ Will listen for %v\n", duration)
|
|
} else {
|
|
fmt.Println("⏸️ Press Ctrl+C to stop")
|
|
}
|
|
|
|
// Set up graceful shutdown
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Handle duration limit
|
|
if duration > 0 {
|
|
go func() {
|
|
select {
|
|
case <-time.After(duration):
|
|
fmt.Println("\n⏰ Duration limit reached, shutting down...")
|
|
cancel()
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Handle interrupt signals
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
go func() {
|
|
select {
|
|
case sig := <-sigChan:
|
|
fmt.Printf("\n🛑 Received signal %v, shutting down...\n", sig)
|
|
cancel()
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}()
|
|
|
|
// Wait for shutdown
|
|
<-ctx.Done()
|
|
|
|
// Disconnect WebSocket
|
|
fmt.Println("🔌 Disconnecting...")
|
|
|
|
if err := wsClient.Disconnect(); err != nil {
|
|
PrintError(fmt.Sprintf("Error during disconnect: %v", err))
|
|
}
|
|
|
|
fmt.Println("✅ Disconnected successfully")
|
|
|
|
return nil
|
|
}
|
|
|
|
// debugMode controls when the WebSocket subscribe loop prints raw frames
|
|
// to stderr. "off" disables debug output entirely (the production default
|
|
// when --debug is unset).
|
|
type debugMode int
|
|
|
|
const (
|
|
debugOff debugMode = iota
|
|
debugAll
|
|
debugUnknown
|
|
debugErrors
|
|
)
|
|
|
|
func parseDebugMode(s string) (debugMode, error) {
|
|
switch strings.TrimSpace(s) {
|
|
case "":
|
|
return debugOff, nil
|
|
case "all":
|
|
return debugAll, nil
|
|
case "unknown":
|
|
return debugUnknown, nil
|
|
case "errors":
|
|
return debugErrors, nil
|
|
default:
|
|
return debugOff, fmt.Errorf("invalid --debug value %q (want one of: all, unknown, errors)", s)
|
|
}
|
|
}
|
|
|
|
// installDebugHook wires an OnRawMessage handler that prints the raw
|
|
// frame to stderr based on the chosen mode. Stays out of stdout so
|
|
// debug output can be filtered/grep'd independently of normal events.
|
|
func installDebugHook(ws *client.WebSocketClient, mode debugMode) {
|
|
ws.OnRawMessage(func(data []byte, parseErr error) {
|
|
switch mode {
|
|
case debugAll:
|
|
printRawFrame(data, parseErr, "all")
|
|
case debugErrors:
|
|
if parseErr != nil {
|
|
printRawFrame(data, parseErr, "errors")
|
|
}
|
|
case debugUnknown:
|
|
// "Unknown" = parsed successfully but no known event types
|
|
// matched. Parse errors also qualify, since they're frames
|
|
// the client couldn't interpret either.
|
|
if parseErr != nil {
|
|
printRawFrame(data, parseErr, "unknown:parse-error")
|
|
return
|
|
}
|
|
|
|
ev, err := models.ParseWebSocketEvent(data)
|
|
if err != nil || len(ev.GetEventTypes()) == 0 {
|
|
printRawFrame(data, err, "unknown")
|
|
}
|
|
case debugOff:
|
|
// nothing
|
|
}
|
|
})
|
|
}
|
|
|
|
func printRawFrame(data []byte, parseErr error, tag string) {
|
|
prefix := "[ws-debug:" + tag + "]"
|
|
if parseErr != nil {
|
|
fmt.Fprintf(os.Stderr, "%s parse-error: %v\n", prefix, parseErr)
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%s %s\n", prefix, string(data))
|
|
}
|
|
|
|
// parseEventFilters validates and parses the filter string
|
|
func parseEventFilters(eventFilter string) map[string]bool {
|
|
validFilters := map[string]bool{
|
|
"nowPlaying": true, "volume": true, "connection": true,
|
|
"preset": true, "zone": true, "group": true, "bass": true,
|
|
"sdkInfo": true, "userActivity": true,
|
|
}
|
|
|
|
if eventFilter == "" {
|
|
return nil
|
|
}
|
|
|
|
filters := make(map[string]bool)
|
|
filterList := strings.Split(eventFilter, ",")
|
|
|
|
for _, f := range filterList {
|
|
f = strings.TrimSpace(f)
|
|
if !validFilters[f] {
|
|
PrintError(fmt.Sprintf("Invalid filter '%s'. Valid filters: %s",
|
|
f, strings.Join(getFilterKeys(validFilters), ", ")))
|
|
os.Exit(1)
|
|
}
|
|
|
|
filters[f] = true
|
|
}
|
|
|
|
return filters
|
|
}
|
|
|
|
// setupWebSocketClient creates and configures the WebSocket client
|
|
func setupWebSocketClient(soundTouchClient *client.Client, reconnect, verbose bool) *client.WebSocketClient {
|
|
wsConfig := &client.WebSocketConfig{
|
|
ReconnectInterval: 5 * time.Second,
|
|
MaxReconnectAttempts: 0, // Unlimited if reconnect enabled
|
|
PingInterval: 30 * time.Second,
|
|
PongTimeout: 10 * time.Second,
|
|
ReadBufferSize: 2048,
|
|
WriteBufferSize: 2048,
|
|
}
|
|
|
|
if verbose {
|
|
wsConfig.Logger = &VerboseLogger{}
|
|
} else {
|
|
wsConfig.Logger = &SilentLogger{}
|
|
}
|
|
|
|
if !reconnect {
|
|
wsConfig.MaxReconnectAttempts = 1
|
|
}
|
|
|
|
return soundTouchClient.NewWebSocketClient(wsConfig)
|
|
}
|
|
|
|
// setupEventHandlers configures all event handlers
|
|
func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]bool, verbose bool) {
|
|
// Now Playing events
|
|
if filters == nil || filters["nowPlaying"] {
|
|
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
|
handleNowPlayingEvent(event, verbose)
|
|
})
|
|
}
|
|
|
|
// Volume events
|
|
if filters == nil || filters["volume"] {
|
|
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
|
|
handleVolumeEvent(event, verbose)
|
|
})
|
|
}
|
|
|
|
// Connection state events
|
|
if filters == nil || filters["connection"] {
|
|
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
|
|
handleConnectionEvent(event)
|
|
})
|
|
}
|
|
|
|
// Preset events
|
|
if filters == nil || filters["preset"] {
|
|
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
|
|
handlePresetEvent(event, verbose)
|
|
})
|
|
}
|
|
|
|
// Zone/Multiroom events
|
|
if filters == nil || filters["zone"] {
|
|
wsClient.OnZoneUpdated(func(event *models.ZoneUpdatedEvent) {
|
|
handleZoneEvent(event)
|
|
})
|
|
}
|
|
|
|
// Stereo-pair (group) events — ST-10 only
|
|
if filters == nil || filters["group"] {
|
|
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
|
|
handleGroupEvent(event)
|
|
})
|
|
}
|
|
|
|
// Bass events
|
|
if filters == nil || filters["bass"] {
|
|
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
|
|
handleBassEvent(event)
|
|
})
|
|
}
|
|
|
|
// Special message handler
|
|
wsClient.OnSpecialMessage(func(message *models.SpecialMessage) {
|
|
handleSpecialMessage(message, filters, verbose)
|
|
})
|
|
|
|
// Unknown events (always enabled for debugging)
|
|
wsClient.OnUnknownEvent(func(event *models.WebSocketEvent) {
|
|
handleUnknownEvent(event, verbose)
|
|
})
|
|
}
|
|
|
|
// Event handlers
|
|
func handleNowPlayingEvent(event *models.NowPlayingUpdatedEvent, verbose bool) {
|
|
fmt.Printf("\n🎵 Now Playing Update [%s]:\n", event.DeviceID)
|
|
np := &event.NowPlaying
|
|
|
|
if np.IsEmpty() {
|
|
fmt.Println(" ⏹️ Nothing playing")
|
|
return
|
|
}
|
|
|
|
fmt.Printf(" 🎵 %s\n", np.GetDisplayTitle())
|
|
|
|
if artist := np.GetDisplayArtist(); artist != "" {
|
|
fmt.Printf(" 👤 %s\n", artist)
|
|
}
|
|
|
|
if np.Album != "" {
|
|
fmt.Printf(" 💿 %s\n", np.Album)
|
|
}
|
|
|
|
fmt.Printf(" 📻 Source: %s\n", np.Source)
|
|
fmt.Printf(" ▶️ Status: %s\n", np.PlayStatus.String())
|
|
|
|
if np.HasTimeInfo() {
|
|
fmt.Printf(" ⏱️ Duration: %s\n", np.FormatDuration())
|
|
}
|
|
|
|
if np.ShuffleSetting != "" {
|
|
fmt.Printf(" 🔀 Shuffle: %s\n", np.ShuffleSetting.String())
|
|
}
|
|
|
|
if np.RepeatSetting != "" {
|
|
fmt.Printf(" 🔁 Repeat: %s\n", np.RepeatSetting.String())
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf(" 📱 Raw Source: %s, Account: %s\n", np.Source, np.SourceAccount)
|
|
|
|
if np.Art != nil && np.Art.URL != "" {
|
|
fmt.Printf(" 🖼️ Artwork: %s\n", np.Art.URL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleVolumeEvent(event *models.VolumeUpdatedEvent, verbose bool) {
|
|
vol := &event.Volume
|
|
fmt.Printf("\n🔊 Volume Update [%s]:\n", event.DeviceID)
|
|
|
|
if vol.IsMuted() {
|
|
fmt.Println(" 🔇 Muted")
|
|
} else {
|
|
fmt.Printf(" 🔊 Level: %d\n", vol.ActualVolume)
|
|
|
|
if vol.TargetVolume != vol.ActualVolume {
|
|
fmt.Printf(" 🎯 Target: %d\n", vol.TargetVolume)
|
|
}
|
|
|
|
fmt.Printf(" 📊 %s\n", models.GetVolumeLevelName(vol.ActualVolume))
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf(" 📱 Sync: %v\n", vol.IsVolumeSync())
|
|
}
|
|
}
|
|
|
|
func handleConnectionEvent(event *models.ConnectionStateUpdatedEvent) {
|
|
cs := &event.ConnectionState
|
|
fmt.Printf("\n🌐 Connection Update [%s]:\n", event.DeviceID)
|
|
|
|
if cs.IsConnected() {
|
|
fmt.Println(" ✅ Connected")
|
|
} else {
|
|
fmt.Printf(" ❌ State: %s\n", cs.State)
|
|
}
|
|
|
|
if cs.Signal != "" {
|
|
fmt.Printf(" 📶 Signal: %s\n", cs.GetSignalStrength())
|
|
}
|
|
}
|
|
|
|
func handlePresetEvent(event *models.PresetUpdatedEvent, verbose bool) {
|
|
presets := &event.Presets
|
|
|
|
deviceHeader := "\n📻 Presets Update"
|
|
if event.DeviceID != "" {
|
|
deviceHeader += fmt.Sprintf(" [%s]", event.DeviceID)
|
|
}
|
|
|
|
fmt.Printf("%s:\n", deviceHeader)
|
|
fmt.Printf(" 📻 Total presets: %d\n", len(presets.Preset))
|
|
|
|
for _, preset := range presets.Preset {
|
|
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
|
|
|
// IsEmpty catches both <preset/> and INVALID_SOURCE
|
|
// placeholders; using the nil-safe helpers below means the
|
|
// inner Printf never dereferences a nil ContentItem.
|
|
if !preset.IsEmpty() {
|
|
fmt.Printf(" %s (%s)", preset.GetDisplayName(), preset.GetSource())
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf(" 📱 Raw presets data: %d total presets\n", len(presets.Preset))
|
|
}
|
|
}
|
|
|
|
func handleZoneEvent(event *models.ZoneUpdatedEvent) {
|
|
zone := &event.Zone
|
|
fmt.Printf("\n🏠 Zone Update [%s]:\n", event.DeviceID)
|
|
fmt.Printf(" 👑 Master: %s\n", zone.Master)
|
|
|
|
if len(zone.Members) > 0 {
|
|
fmt.Printf(" 👥 Members (%d):\n", len(zone.Members))
|
|
|
|
for i, member := range zone.Members {
|
|
fmt.Printf(" %d. %s (%s)\n", i+1, member.DeviceID, member.IP)
|
|
}
|
|
} else {
|
|
fmt.Println(" 👤 Single device (no zone)")
|
|
}
|
|
}
|
|
|
|
func handleGroupEvent(event *models.GroupUpdatedEvent) {
|
|
group := &event.Group
|
|
fmt.Printf("\n🎧 Stereo-Pair Update [%s]:\n", event.DeviceID)
|
|
|
|
if group.IsEmpty() {
|
|
fmt.Println(" ⛓️💥 Pair dissolved (no group configured)")
|
|
return
|
|
}
|
|
|
|
fmt.Printf(" 🆔 ID: %s\n", group.ID)
|
|
fmt.Printf(" 📛 Name: %s\n", group.Name)
|
|
fmt.Printf(" 👑 Master: %s\n", group.MasterDeviceID)
|
|
|
|
if group.Status != "" {
|
|
fmt.Printf(" ✅ Status: %s\n", group.Status)
|
|
}
|
|
|
|
for _, r := range group.Roles.Roles {
|
|
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
|
|
|
if r.IPAddress != "" {
|
|
fmt.Printf(" (IP: %s)", r.IPAddress)
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
}
|
|
|
|
func handleBassEvent(event *models.BassUpdatedEvent) {
|
|
bass := &event.Bass
|
|
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
|
|
fmt.Printf(" 🎚️ Level: %d\n", bass.ActualBass)
|
|
|
|
if bass.TargetBass != bass.ActualBass {
|
|
fmt.Printf(" 🎯 Target: %d\n", bass.TargetBass)
|
|
}
|
|
|
|
levelDesc := "Neutral"
|
|
if bass.ActualBass > 0 {
|
|
levelDesc = "Boosted"
|
|
} else if bass.ActualBass < 0 {
|
|
levelDesc = "Reduced"
|
|
}
|
|
|
|
fmt.Printf(" 📊 %s\n", levelDesc)
|
|
}
|
|
|
|
func handleSpecialMessage(message *models.SpecialMessage, filters map[string]bool, verbose bool) {
|
|
// Check if we should filter this message type
|
|
if filters != nil {
|
|
switch message.Type {
|
|
case models.MessageTypeSdkInfo:
|
|
if !filters["sdkInfo"] {
|
|
return
|
|
}
|
|
case models.MessageTypeUserActivity:
|
|
if !filters["userActivity"] {
|
|
return
|
|
}
|
|
case models.MessageTypeUserInactivity:
|
|
if !filters["userInactivity"] {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
switch message.Type {
|
|
case models.MessageTypeSdkInfo:
|
|
if sdkInfo := message.GetSdkInfo(); sdkInfo != nil {
|
|
fmt.Printf("\n📡 SDK Info:\n")
|
|
fmt.Printf(" 📋 Server Version: %s\n", sdkInfo.ServerVersion)
|
|
fmt.Printf(" 🔧 Server Build: %s\n", sdkInfo.ServerBuild)
|
|
}
|
|
case models.MessageTypeUserActivity:
|
|
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
|
|
|
|
if verbose {
|
|
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
|
}
|
|
case models.MessageTypeUserInactivity:
|
|
fmt.Printf("\n💤 User Inactivity [%s]\n", message.DeviceID)
|
|
|
|
if verbose {
|
|
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
|
}
|
|
default:
|
|
fmt.Printf("\n❓ Unknown Special Message: %s\n", message.String())
|
|
|
|
if verbose {
|
|
fmt.Printf(" 📱 Raw data: %s\n", string(message.RawData))
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleUnknownEvent(event *models.WebSocketEvent, verbose bool) {
|
|
fmt.Printf("\n❓ Unknown Event [%s]:\n", event.DeviceID)
|
|
types := event.GetEventTypes()
|
|
|
|
for _, eventType := range types {
|
|
fmt.Printf(" 📝 Type: %s\n", eventType)
|
|
}
|
|
|
|
if verbose {
|
|
events := event.GetEvents()
|
|
fmt.Printf(" 📱 Event count: %d\n", len(events))
|
|
fmt.Printf(" ⏰ Timestamp: %s\n", event.Timestamp.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
// getFilterKeys extracts keys from filter map
|
|
func getFilterKeys(filters map[string]bool) []string {
|
|
var keys []string
|
|
for k := range filters {
|
|
keys = append(keys, k)
|
|
}
|
|
|
|
return keys
|
|
}
|
|
|
|
// Logger implementations
|
|
type VerboseLogger struct{}
|
|
|
|
func (v *VerboseLogger) Printf(format string, args ...interface{}) {
|
|
timestamp := time.Now().Format("15:04:05")
|
|
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, sanitizeLog(fmt.Sprintf(format, args...)))
|
|
}
|
|
|
|
type SilentLogger struct{}
|
|
|
|
func (s *SilentLogger) Printf(_ string, _ ...interface{}) {
|
|
// Do nothing - silent logging
|
|
}
|