diff --git a/README.md b/README.md index 992d718..6b43e99 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,23 @@ This is an independent project based on the official Bose SoundTouch Web API doc SoundTouch is a trademark of Bose Corporation. +## SoundTouch End of Life Notice + +**Important:** Bose has announced that [SoundTouch cloud support will end on May 6, 2026](https://www.bose.com/soundtouch-end-of-life). + +**What will continue to work:** +- ✅ Local API control (this library's primary functionality) +- ✅ Bluetooth, AirPlay, Spotify Connect, and AUX streaming +- ✅ Remote control features (Play, Pause, Skip, Volume) +- ✅ Multiroom grouping + +**What will stop working:** +- ❌ Presets (preset buttons and app presets) +- ❌ Browsing music services directly from the SoundTouch app +- ❌ Cloud-based features and updates + +This Go library will continue to work as it primarily uses the local Web API for direct device control, which is unaffected by the cloud service discontinuation. + ## Support - 🐛 **Bug Reports**: [Create an issue](https://github.com/gesellix/bose-soundtouch/issues/new) diff --git a/cmd/soundtouch-cli/cmd_clock.go b/cmd/soundtouch-cli/cmd_clock.go index 60b1836..e524eac 100644 --- a/cmd/soundtouch-cli/cmd_clock.go +++ b/cmd/soundtouch-cli/cmd_clock.go @@ -27,20 +27,51 @@ func getClockTime(c *cli.Context) error { return err } + fmt.Println("Clock Time Information:") + if timeObj, err := clockTime.GetTime(); err == nil { - fmt.Printf("Current time: %02d:%02d\n", timeObj.Hour(), timeObj.Minute()) - fmt.Printf("UTC time: %s\n", timeObj.Format("2006-01-02 15:04:05 MST")) + fmt.Printf(" Current time: %s\n", timeObj.Format("2006-01-02 15:04:05")) + fmt.Printf(" Local time: %02d:%02d:%02d\n", timeObj.Hour(), timeObj.Minute(), timeObj.Second()) } else { - fmt.Printf("Time value: %s\n", clockTime.Value) + fmt.Printf(" Parse error: %v\n", err) + + if clockTime.Value != "" { + fmt.Printf(" Raw value: %s\n", clockTime.Value) + } + } + + if clockTime.GetLocalTime() != nil { + lt := clockTime.GetLocalTime() + + fmt.Printf(" Local time details:\n") + fmt.Printf(" Date: %04d-%02d-%02d (day %d)\n", lt.Year, lt.Month+1, lt.DayOfMonth, lt.DayOfWeek) + fmt.Printf(" Time: %02d:%02d:%02d\n", lt.Hour, lt.Minute, lt.Second) } if clockTime.GetUTC() > 0 { utcTime := time.Unix(clockTime.GetUTC(), 0) - fmt.Printf("UTC timestamp: %d (%s)\n", clockTime.GetUTC(), utcTime.Format("2006-01-02 15:04:05 MST")) + fmt.Printf(" UTC timestamp: %d (%s)\n", clockTime.GetUTC(), utcTime.Format("2006-01-02 15:04:05 MST")) + } + + if clockTime.GetTimeFormat() != "" { + fmt.Printf(" Time format: %s\n", clockTime.GetTimeFormat()) + } + + if clockTime.GetBrightness() > 0 { + fmt.Printf(" Brightness: %d\n", clockTime.GetBrightness()) + } + + if clockTime.GetUTCSyncTime() > 0 { + syncTime := time.Unix(clockTime.GetUTCSyncTime(), 0) + fmt.Printf(" Last sync: %s\n", syncTime.Format("2006-01-02 15:04:05 MST")) + } + + if clockTime.GetClockError() != 0 { + fmt.Printf(" Clock error: %d\n", clockTime.GetClockError()) } if clockTime.GetZone() != "" { - fmt.Printf("Time zone: %s\n", clockTime.GetZone()) + fmt.Printf(" Time zone: %s\n", clockTime.GetZone()) } return nil diff --git a/cmd/soundtouch-cli/cmd_token.go b/cmd/soundtouch-cli/cmd_token.go new file mode 100644 index 0000000..42bd59b --- /dev/null +++ b/cmd/soundtouch-cli/cmd_token.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + + "github.com/urfave/cli/v2" +) + +// requestToken requests a new bearer token from the device +func requestToken(c *cli.Context) error { + clientConfig := GetClientConfig(c) + PrintDeviceHeader("Requesting bearer token", clientConfig.Host, clientConfig.Port) + + client, err := CreateSoundTouchClient(clientConfig) + if err != nil { + PrintError(fmt.Sprintf("Failed to create client: %v", err)) + return err + } + + token, err := client.RequestToken() + if err != nil { + PrintError(fmt.Sprintf("Failed to request token: %v", err)) + return err + } + + fmt.Println("Bearer Token Information:") + + if token.IsValid() { + fmt.Printf(" Status: Valid\n") + fmt.Printf(" Token: %s\n", token.String()) + fmt.Printf(" Full value: %s\n", token.GetToken()) + fmt.Printf(" Authorization header: %s\n", token.GetAuthHeader()) + + // Display token without Bearer prefix for API usage + fmt.Println("\nFor API Usage:") + fmt.Printf(" Raw token: %s\n", token.GetTokenWithoutPrefix()) + + // Usage instructions + fmt.Println("\nUsage Instructions:") + fmt.Println(" • Use the 'Authorization header' value in HTTP Authorization headers") + fmt.Println(" • Use the 'Raw token' value when an API requires token without 'Bearer ' prefix") + fmt.Println(" • Tokens are generated per request and may have expiration times") + + // Security notice + fmt.Println("\nSecurity Notice:") + fmt.Println(" • Store tokens securely and avoid logging them in plain text") + fmt.Println(" • Tokens provide authentication - treat them as passwords") + fmt.Println(" • Request new tokens when needed rather than reusing old ones") + } else { + fmt.Printf(" Status: Invalid\n") + fmt.Printf(" Raw response: %s\n", token.GetToken()) + PrintError("Received invalid bearer token from device") + } + + return nil +} diff --git a/cmd/soundtouch-cli/main.go b/cmd/soundtouch-cli/main.go index 1927368..7ae1bb2 100644 --- a/cmd/soundtouch-cli/main.go +++ b/cmd/soundtouch-cli/main.go @@ -962,6 +962,20 @@ func main() { }, }, }, + // Token commands + { + Name: "token", + Aliases: []string{"t"}, + Usage: "Bearer token management commands", + Subcommands: []*cli.Command{ + { + Name: "request", + Usage: "Request a new bearer token from the device", + Action: requestToken, + Before: RequireHost, + }, + }, + }, }, } diff --git a/docs/API-Endpoints-Overview.md b/docs/API-Endpoints-Overview.md index d8c2cdb..540110d 100644 --- a/docs/API-Endpoints-Overview.md +++ b/docs/API-Endpoints-Overview.md @@ -58,6 +58,8 @@ Retrieves information about the currently playing music. ### POST /key ✅ **Implemented** Sends key commands to the device. +**IMPORTANT - Key values, state, and sender attributes are CaSe-SeNsItIvE!** + **Important**: Proper key simulation requires sending both press and release states: **Request XML (Press + Release):** @@ -66,6 +68,11 @@ Sends key commands to the device. KEY_NAME ``` +**Response XML:** +```xml +/key +``` + **Available Keys:** **Playback Controls:** @@ -74,11 +81,14 @@ Sends key commands to the device. - `STOP` - Stop current playback - `PREV_TRACK` - Go to previous track - `NEXT_TRACK` - Go to next track +- `PLAY_PAUSE` - Toggles between play and pause for currently playing media **Rating and Bookmark Controls:** -- `THUMBS_UP` - Rate current content positively (Pandora, etc.) -- `THUMBS_DOWN` - Rate current content negatively +- `THUMBS_UP` - Rate current content positively (Pandora, Spotify, etc.) +- `THUMBS_DOWN` - Rate current content negatively (Pandora, Spotify, etc.) - `BOOKMARK` - Bookmark current content +- `ADD_FAVORITE` - Adds currently playing media to device favorites (Pandora, Spotify, etc.) +- `REMOVE_FAVORITE` - Removes currently playing media from device favorites (Pandora, Spotify, etc.) **Power and System Controls:** - `POWER` - Toggle device power state @@ -103,6 +113,19 @@ Sends key commands to the device. - `REPEAT_ONE` - Repeat current track - `REPEAT_ALL` - Repeat all tracks in playlist +**State Values:** +- `press` - Indicates the key is pressed +- `release` - Indicates the key is released +- `repeat` - Indicates the key is repeated + +**Sender Values:** +- `Gabbo` - Default value for standard SoundTouch remote control device +- `IrRemote` - IR remote control device +- `Console` - Console device +- `LightswitchRemote` - Lightswitch remote device +- `BoselinkRemote` - Boselink remote device +- `Etap` - Etap device + ## Volume Control ### GET /volume ✅ **Implemented** @@ -139,13 +162,15 @@ Retrieves the current bass settings. ``` ### POST /bass ✅ **Implemented** -Sets the bass settings (-9 to +9). +Sets the bass settings. Range varies by device - check `/bassCapabilities` for supported range. **Request XML:** ```xml 0 ``` +**Note**: Value must be within the range specified by `bassMin` and `bassMax` from `/bassCapabilities` service. + ## Source Management ### GET /sources ✅ **Implemented** @@ -221,20 +246,58 @@ Retrieves multiroom zone information. Configures multiroom zones. ### GET /balance ✅ **Implemented** -Retrieves balance settings (stereo devices). +Retrieves balance settings (stereo devices). Only works if device is configured as part of a stereo pair. + +**Response XML:** +```xml + + true + -7 + 7 + 0 + 0 + 0 + +``` ### POST /balance ✅ **Implemented** -Sets balance settings. +Sets balance settings. Value must be within the range specified by `balanceMin` and `balanceMax`. + +**Request XML:** +```xml + + 0 + +``` + +**Range Examples:** +- `-7` = left speaker +- `0` = centered +- `7` = right speaker ### GET /clockTime ✅ **Implemented** Retrieves the device time. +**Response XML:** +```xml + + + +``` + ### POST /clockTime ✅ **Implemented** Sets the device time. ### GET /clockDisplay ✅ **Implemented** Retrieves clock display settings. +**Response XML:** +```xml + + + +``` + ### POST /clockDisplay ✅ **Implemented** Configures the clock display. @@ -254,22 +317,39 @@ Establishes a persistent connection for live updates. ### GET /networkInfo ✅ **Implemented** Retrieves network information. +**Response XML:** +```xml + + + + + + +``` + ### GET /capabilities ✅ **Implemented** Retrieves device capabilities. -### GET /name 🔍 **Extra** +### GET /name 🔍 **Extra** Retrieves the device name. +**Response XML:** +```xml +SoundTouch 10 +``` + **Note**: Official API only documents `POST /name` for setting device name. Our GET implementation appears to be an undocumented extension. ### POST /name ✅ **Implemented** -Sets the device name via `SetName()` method. +Sets the device name via `SetName()` method. If name is changed, the change will be detected immediately via ZeroConf services. -**Official Request Format:** +**Request XML:** ```xml -$STRING +SoundTouch Living Room ``` +**Response**: Returns same structure as `/info` endpoint with updated name. + ### GET /bassCapabilities ✅ **Implemented** Checks if bass customization is supported on the device. @@ -284,9 +364,18 @@ Checks if bass customization is supported on the device. ``` ### GET /trackInfo ✅ **Implemented** -Gets track information (duplicate of `/now_playing` per official API). +Gets extended track information for currently playing music service media. -**Status**: Fully implemented but times out on SoundTouch 10 & 20 test devices (AllegroWebserver timeout). May work on other SoundTouch models or firmware versions. Use `/now_playing` endpoint as reliable alternative. +**Response XML:** +```xml +Track Name;extended details;separated by semicolons; +``` + +**Important Notes:** +- Only returns information if currently playing content is from a music service (PANDORA, SPOTIFY, etc.) +- If playing non-music-service content (AIRPLAY, STORED_MUSIC, etc.), service becomes unresponsive for ~30 seconds until timeout +- Extended details are delimited by semicolons (e.g., "Who You Are To Me (feat. Lady A);vocal duets;upbeat lyrics;") +- Times out on some SoundTouch models - use `/now_playing` as reliable alternative **Implementation**: Available via `GetTrackInfo()` method. Consider using `GetNowPlaying()` method for guaranteed compatibility. @@ -342,6 +431,26 @@ These endpoints work with real hardware but are NOT in official API v1.0: **Note**: Not documented in official API v1.0 but works with real devices. +### Token Management ✅ **Implemented** + +#### GET /requestToken ✅ **Implemented** +Generates a new bearer token from the device for authentication purposes. + +**Response XML:** +```xml + +``` + +**Usage:** +- Tokens are generated per request and may have expiration times +- Use for HTTP Authorization headers: `Authorization: Bearer ` +- Store tokens securely and treat as passwords +- Request new tokens when needed rather than reusing old ones + +**Implementation**: Available via `RequestToken()` method + +**Testing**: Integration tests available - run with `SOUNDTOUCH_TEST_HOST= go test ./pkg/client -run TestRequestToken_Integration` to validate real device token generation without exposing token values + ## Coverage Summary ### Official API Coverage: 100% @@ -351,6 +460,13 @@ These endpoints work with real hardware but are NOT in official API v1.0: - **Device-Dependent**: 1 (5%) - GET /trackInfo times out on some models - **Excluded**: 1 endpoint (POST /presets officially N/A) +### Real Device Discovery: 103 Endpoints Found +- **Total Discovered Endpoints**: 103 (from /supportedURLs) +- **Currently Implemented**: ~35 (34%) +- **Core Functionality**: 100% implemented +- **Extended Features**: Many undocumented endpoints available +- **Implementation Focus**: User-facing and essential system endpoints prioritized + ### Feature Coverage: 100% - ✅ All available user functionality implemented - ✅ All functional device operations supported @@ -358,6 +474,7 @@ These endpoints work with real hardware but are NOT in official API v1.0: - ✅ Full multiroom capabilities - ✅ Complete advanced audio controls (where supported by device) - 🔍 Additional features beyond official specification +- 🔍 68 additional undocumented endpoints discovered but not yet implemented ## Error Handling @@ -407,6 +524,180 @@ func SendKey(deviceIP string, key string) error { 4. **Rate Limiting**: No explicit limits documented, but moderate usage recommended 5. **Device Discovery**: Devices can be found via UPnP on the local network +## Comprehensive Endpoint Discovery + +### GET /supportedURLs ✅ **Implemented** +Retrieves all supported endpoints for the specific device. + +**Response XML Structure:** +```xml + + + + + +``` + +**Complete Endpoint List** (103 endpoints discovered from real devices): + +**Core Device Information:** +- `/info` ✅ - Device information +- `/capabilities` ✅ - Device capabilities +- `/supportedURLs` ✅ - This endpoint (self-reference) +- `/networkInfo` ✅ - Network configuration +- `/name` ✅ - Device name management +- `/netStats` - Network statistics +- `/powerManagement` - Power state and battery information +- `/soundTouchConfigurationStatus` - Device configuration status + +**Playback and Media Control:** +- `/nowPlaying` ✅ - Current playback status +- `/now_playing` ✅ - Alternative current playback endpoint +- `/nowSelection` - Current selection details +- `/key` ✅ - Send key commands +- `/select` ✅ - Select source/content +- `/playbackRequest` - Advanced playback requests +- `/userPlayControl` - User play control interface (PAUSE_CONTROL, PLAY_CONTROL, etc.) +- `/userTrackControl` - User track control interface +- `/userRating` - User rating interface (UP/DOWN for Pandora, etc.) + +**Volume and Audio:** +- `/volume` ✅ - Volume control +- `/bass` ✅ - Bass settings +- `/bassCapabilities` ✅ - Bass capability info +- `/balance` ✅ - Stereo balance +- `/DSPMonoStereo` - DSP mono/stereo settings + +**Sources and Content:** +- `/sources` ✅ - Available sources +- `/sourceDiscoveryStatus` - Source discovery status +- `/nameSource` - Name/rename sources +- `/selectLastSource` - Select last used source +- `/selectLastWiFiSource` - Select last WiFi source +- `/selectLastSoundTouchSource` - Select last SoundTouch source +- `/selectLocalSource` - Select local source + +**Presets and Favorites:** +- `/presets` ✅ - Preset management +- `/storePreset` - Store new preset (max 6 presets) +- `/removePreset` - Remove existing preset +- `/selectPreset` - Select preset by ID +- `/recents` ✅ - Recently played content +- `/bookmark` - Bookmark current content + +**Music Services:** +- `/setMusicServiceAccount` - Configure music service account (Pandora, Spotify, etc.) +- `/setMusicServiceOAuthAccount` - OAuth account setup +- `/removeMusicServiceAccount` - Remove music service account +- `/serviceAvailability` - Check service availability +- `/introspect` - Get introspect data for specific sources + +**Station Management (Radio/Streaming):** +- `/searchStation` - Search for stations (tested with Pandora) +- `/addStation` - Add station to favorites (tested with Pandora) +- `/removeStation` - Remove station from favorites (tested with Pandora) +- `/genreStations` - Browse stations by genre +- `/stationInfo` - Station information +- `/trackInfo` ✅ - Extended track information with semicolon-delimited details + +**Zone and Multiroom:** +- `/getZone` ✅ - Get zone configuration +- `/setZone` ✅ - Set zone configuration +- `/addZoneSlave` ✅ - Add device to zone +- `/removeZoneSlave` ✅ - Remove device from zone +- `/addGroup` - Add to speaker group +- `/removeGroup` - Remove from speaker group +- `/getGroup` - Get group configuration +- `/updateGroup` - Update group settings + +**Clock and Display:** +- `/clockDisplay` ✅ - Clock display settings +- `/clockTime` ✅ - Device time management + +**System and Configuration:** +- `/powerManagement` - Power management settings +- `/standby` - Standby mode control +- `/lowPowerStandby` - Low power standby mode +- `/systemtimeout` - System timeout settings +- `/powersaving` - Power saving configuration +- `/userActivity` - User activity tracking +- `/language` - Language settings +- `/speaker` - Speaker configuration + +**Network and Connectivity:** +- `/performWirelessSiteSurvey` - WiFi site survey (returns detected networks with signal strength) +- `/addWirelessProfile` - Add WiFi profile (supports various security types) +- `/getActiveWirelessProfile` - Get active WiFi profile +- `/setWiFiRadio` - WiFi radio control + +**Bluetooth:** +- `/bluetoothInfo` ✅ - Bluetooth information and pairing status +- `/enterBluetoothPairing` - Enter Bluetooth pairing mode (switches to BLUETOOTH source) +- `/clearBluetoothPaired` - Clear all Bluetooth pairings (emits descending tone) + +**Pairing and Setup:** +- `/pairLightswitch` - Pair with lightswitch accessory +- `/cancelPairLightswitch` - Cancel lightswitch pairing +- `/clearPairedList` - Clear all pairings +- `/enterPairingMode` - Enter general pairing mode +- `/setPairedStatus` - Set pairing status +- `/setPairingStatus` - Update pairing status +- `/soundTouchConfigurationStatus` - Configuration status +- `/setup` - Device setup interface + +**Software Updates:** +- `/swUpdateStart` - Start software update +- `/swUpdateAbort` - Abort software update +- `/swUpdateQuery` - Query update status +- `/swUpdateCheck` - Check for updates + +**Advanced Features:** +- `/search` - Content search (music libraries with filter support) +- `/navigate` - Content navigation (traverse music library containers) +- `/listMediaServers` - List available UPnP/DLNA media servers +- `/requestToken` ✅ - Bearer token generation +- `/notification` - Notification management +- `/playNotification` - Play notification beep (ST-10 series only) +- `/speaker` - Play TTS messages or URL content (ST-10 series only) +- `/test` - System test interface + +**Internal/System:** +- `/pdo` - Internal PDO operations +- `/slaveMsg` - Slave device messaging +- `/masterMsg` - Master device messaging +- `/factoryDefault` - Factory reset +- `/criticalError` - Critical error handling +- `/netStats` - Network statistics and device interface details +- `/rebroadcastlatencymode` - Rebroadcast latency mode configuration +- `/systemtimeout` - System timeout settings +- `/powersaving` - Power saving configuration + +**Product Information:** +- `/setProductSerialNumber` - Set product serial number +- `/setProductSoftwareVersion` - Set software version +- `/setComponentSoftwareVersion` - Set component versions + +**Marge Integration (Bose Cloud Services):** +- `/marge` - Marge service integration (Bose cloud services, EOL May 2026) +- `/setMargeAccount` - Set Marge account (EOL May 2026) +- `/pushCustomerSupportInfoToMarge` - Push support info to cloud (EOL May 2026) + +**Reset and Control:** +- `/getBCOReset` - Get BCO reset status +- `/setBCOReset` - Set BCO reset + +**Notes on Endpoint Discovery:** +- Total discovered endpoints: **103** +- Both test devices (192.168.178.28 and 192.168.178.35) support identical endpoint lists +- Many endpoints are undocumented in official API v1.0 but functional on real hardware +- Some endpoints may require specific device types or firmware versions +- Endpoints marked ✅ are currently implemented in this Go library + +**Implementation Priority:** +1. **High**: Core functionality endpoints already implemented +2. **Medium**: Music service integration, advanced zone management +3. **Low**: Internal/diagnostic endpoints, factory operations + ## Reference Based on the official Bose SoundTouch Web API documentation: diff --git a/docs/SUPPORTEDURLS-ANALYSIS.md b/docs/SUPPORTEDURLS-ANALYSIS.md new file mode 100644 index 0000000..1071c63 --- /dev/null +++ b/docs/SUPPORTEDURLS-ANALYSIS.md @@ -0,0 +1,236 @@ +# SoundTouch supportedURLs Endpoint Analysis + +This document provides a comprehensive analysis of the `/supportedURLs` endpoint response from real Bose SoundTouch devices and compares it with our current implementation. + +## Discovery Summary + +**Test Devices:** +- Device 1: `192.168.178.28:8090` (deviceID: `08DF1F0BA325`) +- Device 2: `192.168.178.35:8090` (deviceID: `A81B6A536A98`) + +**Key Findings:** +- Both devices return identical endpoint lists +- **103 total endpoints** discovered +- **~35 currently implemented** in this Go library (34%) +- **68 additional endpoints** available for future implementation + +## Endpoint Categories + +### ✅ Fully Implemented (Core Functionality) + +**Device Information (5/5):** +- `/info` - Device information +- `/capabilities` - Device capabilities +- `/supportedURLs` - Supported endpoints list +- `/networkInfo` - Network configuration +- `/name` - Device name management + +**Playback Control (3/3):** +- `/nowPlaying` - Current playback status +- `/now_playing` - Alternative current playback endpoint +- `/key` - Send key commands + +**Volume & Audio (4/4):** +- `/volume` - Volume control +- `/bass` - Bass settings +- `/bassCapabilities` - Bass capability information +- `/balance` - Stereo balance + +**Source Management (2/2):** +- `/sources` - Available sources +- `/select` - Select source/content + +**Preset Management (1/2):** +- `/presets` - Get presets (POST officially N/A) + +**Zone/Multiroom (4/4):** +- `/getZone` - Get zone configuration +- `/setZone` - Set zone configuration +- `/addZoneSlave` - Add device to zone +- `/removeZoneSlave` - Remove device from zone + +**Clock & Display (2/2):** +- `/clockDisplay` - Clock display settings +- `/clockTime` - Device time management + +**Advanced Audio (3/3):** +- `/audiodspcontrols` - DSP settings (capability-dependent) +- `/audioproducttonecontrols` - Advanced tone controls (capability-dependent) +- `/audioproductlevelcontrols` - Speaker level controls (capability-dependent) + +**System Info (3/3):** +- `/trackInfo` - Track information +- `/bluetoothInfo` - Bluetooth information +- `/recents` - Recently played content + +### 🔶 Partially Implemented/Different Approach + +**Zone Management:** +- `/addGroup` ⚠️ - We use `/setZone` for group management +- `/removeGroup` ⚠️ - We use `/setZone` for group management +- `/getGroup` ⚠️ - We use `/getZone` for group information +- `/updateGroup` ⚠️ - We use `/setZone` for group updates + +### ❌ Not Yet Implemented (High Priority) + +**Enhanced Playback Control:** +- `/nowSelection` - Current selection details +- `/playbackRequest` - Advanced playback requests +- `/userPlayControl` - User play control interface +- `/userTrackControl` - User track control interface +- `/selectPreset` - Select preset by ID + +**Source Enhancement:** +- `/sourceDiscoveryStatus` - Source discovery status +- `/nameSource` - Name/rename sources +- `/selectLastSource` - Select last used source +- `/selectLastWiFiSource` - Select last WiFi source +- `/selectLastSoundTouchSource` - Select last SoundTouch source +- `/selectLocalSource` - Select local source + +**Music Services Integration:** +- `/setMusicServiceAccount` - Configure music service account +- `/setMusicServiceOAuthAccount` - OAuth account setup +- `/removeMusicServiceAccount` - Remove music service account +- `/serviceAvailability` - Check service availability + +**Enhanced Presets:** +- `/storePreset` - Store new preset +- `/removePreset` - Remove existing preset +- `/bookmark` - Bookmark current content +- `/userRating` - User rating for content + +**Station/Radio Management:** +- `/searchStation` - Search for stations +- `/addStation` - Add station to favorites +- `/removeStation` - Remove station from favorites +- `/genreStations` - Browse stations by genre +- `/stationInfo` - Station information + +### ❌ Not Yet Implemented (Medium Priority) + +**System Configuration:** +- `/powerManagement` - Power management settings +- `/standby` - Standby mode control +- `/lowPowerStandby` - Low power standby mode +- `/systemtimeout` - System timeout settings +- `/powersaving` - Power saving configuration +- `/language` - Language settings +- `/speaker` - Speaker configuration + +**Network & Connectivity:** +- `/performWirelessSiteSurvey` - WiFi site survey +- `/addWirelessProfile` - Add WiFi profile +- `/getActiveWirelessProfile` - Get active WiFi profile +- `/setWiFiRadio` - WiFi radio control + +**Bluetooth Enhancement:** +- `/enterBluetoothPairing` - Enter Bluetooth pairing mode +- `/clearBluetoothPaired` - Clear Bluetooth pairings + +**Content Discovery:** +- `/search` - Content search +- `/navigate` - Content navigation +- `/listMediaServers` - List available media servers + +### ❌ Not Yet Implemented (Low Priority) + +**Pairing & Setup:** +- `/pairLightswitch` - Pair with lightswitch accessory +- `/cancelPairLightswitch` - Cancel lightswitch pairing +- `/clearPairedList` - Clear all pairings +- `/enterPairingMode` - Enter general pairing mode +- `/setPairedStatus` - Set pairing status +- `/setPairingStatus` - Update pairing status +- `/soundTouchConfigurationStatus` - Configuration status +- `/setup` - Device setup interface + +**Software Updates:** +- `/swUpdateStart` - Start software update +- `/swUpdateAbort` - Abort software update +- `/swUpdateQuery` - Query update status +- `/swUpdateCheck` - Check for updates + +**System Utilities:** +- `/userActivity` - User activity tracking +- `/requestToken` - Token management +- `/notification` - Notification management +- `/playNotification` - Play notification sound +- `/introspect` - System introspection +- `/test` - System test interface + +**Internal/Advanced:** +- `/pdo` - Internal PDO operations +- `/slaveMsg` - Slave device messaging +- `/masterMsg` - Master device messaging +- `/factoryDefault` - Factory reset +- `/criticalError` - Critical error handling +- `/netStats` - Network statistics +- `/rebroadcastlatencymode` - Rebroadcast latency mode +- `/getBCOReset` - Get BCO reset status +- `/setBCOReset` - Set BCO reset + +**Product Management:** +- `/setProductSerialNumber` - Set product serial number +- `/setProductSoftwareVersion` - Set software version +- `/setComponentSoftwareVersion` - Set component versions + +**Cloud Integration (EOL May 2026):** +- `/marge` - Marge service integration +- `/setMargeAccount` - Set Marge account +- `/pushCustomerSupportInfoToMarge` - Push support info to cloud + +**Enhanced DSP (Device Dependent):** +- `/DSPMonoStereo` - DSP mono/stereo settings + +## Implementation Recommendations + +### Phase 1: High-Value User Features +1. **Enhanced Source Selection** - `/selectLast*` endpoints for better UX +2. **Preset Management** - `/storePreset`, `/removePreset`, `/selectPreset` +3. **Station Management** - Radio/streaming station operations +4. **Music Service Integration** - Account management endpoints + +### Phase 2: System Enhancement +1. **Power Management** - Standby and power saving controls +2. **Network Management** - WiFi profile and radio control +3. **Content Discovery** - Search and navigation capabilities +4. **Bluetooth Enhancement** - Pairing management + +### Phase 3: Advanced Features +1. **System Diagnostics** - Network stats, introspection +2. **Update Management** - Software update control +3. **Notification System** - Notification management +4. **Advanced Setup** - Pairing and configuration tools + +## Notes + +1. **Device Consistency**: Both test devices expose identical endpoint lists, suggesting consistent firmware behavior across SoundTouch models. + +2. **Official vs. Real**: The device exposes **84 additional endpoints** beyond the 19 documented in the official API v1.0, indicating significant undocumented functionality. + +3. **Cloud Dependency**: Some endpoints (especially `/marge*`) may become non-functional after the May 2026 SoundTouch cloud EOL. + +4. **Implementation Strategy**: Focus on user-facing functionality first, then system management, finally internal/diagnostic features. + +5. **Testing Required**: Each new endpoint implementation should be tested against real hardware to verify functionality and response formats. + +6. **Documentation Gap**: Many endpoints lack official documentation, requiring reverse engineering through testing. + +## Raw Device Response + +**Device Count:** 103 unique endpoints +**Response Format:** XML with URL location attributes +**Common Pattern:** Most endpoints support both GET (query) and POST (modify) operations + +**Example Response Structure:** +```xml + + + + + + +``` + +This analysis provides a roadmap for expanding the Go library's API coverage from 34% to potentially 100% of available device functionality. \ No newline at end of file diff --git a/docs/UNIMPLEMENTED-ENDPOINTS.md b/docs/UNIMPLEMENTED-ENDPOINTS.md new file mode 100644 index 0000000..d02a631 --- /dev/null +++ b/docs/UNIMPLEMENTED-ENDPOINTS.md @@ -0,0 +1,785 @@ +# Unimplemented SoundTouch API Endpoints + +This document provides detailed information about SoundTouch API endpoints that are supported by real hardware but not yet implemented in this Go library. The examples and XML structures are based on real device responses and the comprehensive [HomeAssistant SoundTouch Plus documentation](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API). + +## High Priority Implementation Candidates + +### Music Service Management + +#### POST /setMusicServiceAccount +Adds a music service account to the sources list. + +**Request Examples:** + +Pandora: +```xml + + YourPandoraUserId + YourPandoraPassword$1pd + +``` + +NAS Music Library: +```xml + + d09708a1-5953-44bc-a413-123456789012/0 + + +``` + +**Response:** +```xml +/setMusicServiceAccount +``` + +**Notes:** +- UPnP media servers must be detected first (check `/listMediaServers`) +- Note the `/0` suffix for STORED_MUSIC user names + +#### POST /removeMusicServiceAccount +Removes an existing music service account from the sources list. + +**Request Examples:** + +Remove Pandora: +```xml + + YourPandoraUserId + + +``` + +Remove NAS Library: +```xml + + d09708a1-5953-44bc-a413-123456789012/0 + + +``` + +### Enhanced Preset Management + +#### POST /storePreset +Stores a preset to the device (maximum 6 presets). + +**Request XML:** +```xml + + + K-LOVE 90s + http://cdn-profiles.tunein.com/s309605/images/logog.png + + +``` + +**Response:** Returns updated presets list + +**Behavior:** +- If preset ID exists, overlay existing preset +- If content matches existing preset, move to specified slot +- Generates `presetsUpdated` WebSocket event + +#### POST /removePreset +Removes an existing preset from the device. + +**Request XML:** +```xml + +``` + +**Response:** Returns updated presets list +**WebSocket Event:** `presetsUpdated` + +#### GET /selectPreset +Selects a preset by ID for playback. + +**Usage:** Send preset ID to immediately play stored preset content. + +### Station Management (Pandora Tested) + +#### POST /searchStation +Searches music service for stations that can be added. + +**Request XML:** +```xml + + Zach Williams + +``` + +**Response XML:** +```xml + + + + Cornerstone (Radio Edit) (feat. Zach Williams) + TobyMac + http://mediaserver-cont-usc-mp1-1-v4v6.pandora.com/images/.../1080W_1080H.jpg + + + + + + Zach Williams + http://mediaserver-cont-dc6-2-v4v6.pandora.com/images/.../1080W_1080H.jpg + + + + +``` + +#### POST /addStation +Adds a station to music service collection. + +**Request XML:** +```xml + + Zach Williams & Essential Worship + +``` + +**Response:** +```xml +/addStation +``` + +**Notes:** +- Station is immediately selected for playing +- Use token from `/searchStation` results + +#### POST /removeStation +Removes a station from music service collection. + +**Request XML:** +```xml + + Zach Williams Radio + +``` + +**Response:** +```xml +/removeStation +``` + +**Behavior:** +- If removed station is currently playing, playback stops and source becomes "INVALID_SOURCE" + +### Enhanced User Controls + +#### POST /userPlayControl +Sends user play control commands. + +**Request XML:** +```xml +PLAY_CONTROL +``` + +**Valid Control Values:** +- `PAUSE_CONTROL` - Pause currently playing content +- `PLAY_CONTROL` - Play content that is paused/stopped +- `PLAY_PAUSE_CONTROL` - Toggle play/pause state +- `STOP_CONTROL` - Stop currently playing content + +**Response:** +```xml +/userPlayControl +``` + +#### POST /userRating +Rates currently playing media (Pandora support confirmed). + +**Request XML:** +```xml +UP +``` + +**Valid Rating Values:** +- `UP` - Thumbs up rating +- `DOWN` - Thumbs down rating (stops current track, advances to next) + +**Response:** +```xml +/userRating +``` + +**Notes:** +- Ratings stored in artist profile under "My Collection" +- Currently only works with Pandora + +## Medium Priority Implementation Candidates + +### Content Discovery and Navigation + +#### POST /navigate +Returns child container items from music library containers. + +**Request XML (Root Container):** +```xml + + 1 + 1000 + +``` + +**Response XML:** +```xml + + 4 + + + Music + dir + + Music + + + + Playlists + dir + + Playlists + + + + +``` + +**Navigate Specific Container:** +```xml + + 1 + 1000 + + Welcome to the New + dir + + Welcome to the New + + + +``` + +#### POST /search +Searches specified music library container. + +**Request XML:** +```xml + + 1 + 1000 + baby + + Music Playlists + dir + + + +``` + +**Response XML:** +```xml + + 2 + + + Baby, It's Cold Outside + track + + Baby, It's Cold Outside + + Anne Murray + Christmas Album + + + +``` + +**Valid Filters:** +- `track` - Search track names +- `artist` - Search artist names +- `album` - Search album names + +### Power Management + +#### GET /powerManagement +Returns power state and battery capability information. + +**Response XML:** +```xml + + FullPower + + false + + +``` + +#### GET /standby +Places device into standby (power-saving) mode. + +**Response:** +```xml +/standby +``` + +**WebSocket Event:** +```xml + + + + + + + +``` + +#### GET /lowPowerStandby +Places device into low-power mode (device becomes unresponsive until physical power button pressed). + +**Response:** +```xml +/lowPowerStandby +``` + +**Warning:** Device will not respond to any commands after this until physically powered on. + +### Language and Configuration + +#### GET /language +Returns current language configuration. + +**Response XML:** +```xml +3 +``` + +#### POST /language +Sets device language. + +**Request XML:** +```xml +3 +``` + +**Language Codes:** +- 1 = DANISH +- 2 = GERMAN +- 3 = ENGLISH +- 4 = SPANISH +- 5 = FRENCH +- 6 = ITALIAN +- 7 = DUTCH +- 8 = SWEDISH +- 9 = JAPANESE +- 10 = SIMPLIFIED_CHINESE +- 11 = TRADITIONAL_CHINESE +- 12 = KOREAN +- 13 = THAI +- 15 = CZECH +- 16 = FINNISH +- 17 = GREEK +- 18 = NORWEGIAN +- 19 = POLISH +- 20 = PORTUGUESE +- 21 = ROMANIAN +- 22 = RUSSIAN +- 23 = SLOVENIAN +- 24 = TURKISH +- 25 = HUNGARIAN + +### System Information and Status + +#### GET /soundTouchConfigurationStatus +Returns current SoundTouch configuration status. + +**Response XML:** +```xml + +``` + +**Valid Status Values:** +- `SOUNDTOUCH_CONFIGURED` - Device configuration complete +- `SOUNDTOUCH_NOT_CONFIGURED` - Device not configured +- `SOUNDTOUCH_CONFIGURING` - Configuration in progress + +#### GET /serviceAvailability +Returns information about which source services are currently available. + +**Response XML:** +```xml + + + + + + + + + + + +``` + +#### GET /listMediaServers +Returns information about detected UPnP/DLNA media servers. + +**Response XML:** +```xml + + + +``` + +#### GET /requestToken ✅ **Implemented** +Returns a new bearer token generated by the device. + +**Response XML:** +```xml + +``` + +**Implementation**: Available via `RequestToken()` client method and `soundtouch-cli token request` command. + +### Software Updates + +#### GET /swUpdateCheck +Gets latest available software update release information. + +**Response XML:** +```xml + + + +``` + +#### GET /swUpdateQuery +Gets status of a SoundTouch software update. + +**Response XML:** +```xml + + IDLE + 0 + false + +``` + +## Low Priority / Specialized Endpoints + +### Notification System (ST-10 Series Only) + +#### POST /playNotification +Plays a notification beep on the device. + +**Response:** +```xml +/playNotification +``` + +**Behavior:** +- Pauses current media +- Emits double beep sound +- Resumes media playback + +**Note:** Only works on ST-10 series. ST-300 and other models do not support this. + +#### POST /speaker +Plays TTS messages or URL content (ST-10 Series Only). + +**TTS Example:** +```xml + + http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=a.There%20is%20activity%20at%20the%20front%20door. + YourAppKey + TTS Notification + Google TTS + a.There is activity at the front door. + 70 + +``` + +**URL Content Example:** +```xml + + https://freetestdata.com/wp-content/uploads/2021/09/Free_Test_Data_1MB_MP3.mp3 + YourAppKey + FreeTestData.com + MP3 Test Data + Free_Test_Data_1MB_MP3 + +``` + +**Notes:** +- Only ST-10 series supported +- Pauses current content during notification +- Volume restored after notification completes +- SoundTouch device limits volume range 10-70 + +### WiFi Management + +#### POST /performWirelessSiteSurvey +Gets list of wireless networks detected by device. + +**Response XML:** +```xml + + + + + wpa_or_wpa2 + + + + + wpa_or_wpa2 + + + + +``` + +#### POST /addWirelessProfile +Adds wireless profile configuration to device. + +**Request XML:** +```xml + + + +``` + +**Security Types:** +- `none` - No security +- `wep` - WEP +- `wpatkip` - WPA/TKIP +- `wpaaes` - WPA/AES +- `wpa2tkip` - WPA2/TKIP +- `wpa2aes` - WPA2/AES +- `wpa_or_wpa2` - WPA/WPA2 (recommended) + +**Setup Process:** +1. Connect to device WiFi (default IP: 192.0.2.1) +2. Add wireless profile +3. End setup: POST to `/setup` with `` + +#### GET /getActiveWirelessProfile +Gets current wireless profile configuration. + +**Response XML:** +```xml + + my_wireless_ssid + +``` + +### Bluetooth Management + +#### POST /enterBluetoothPairing +Enters Bluetooth pairing mode and waits for device to pair. + +**Response:** +```xml +/enterBluetoothPairing +``` + +**Behavior:** +- Device enters pairing mode +- Bluetooth indicator turns blue +- Emits ascending tone when pairing complete +- Source immediately switches to BLUETOOTH + +#### POST /clearBluetoothPaired +Clears all existing Bluetooth pairings. + +**Response:** +```xml + +``` + +**Behavior:** +- All existing pairings removed +- Devices need to re-pair +- Emits descending tone + +### Source Selection Shortcuts + +#### GET /selectLastSource +Selects the last source that was selected. + +**Response:** +```xml +/selectLastSource +``` + +#### GET /selectLastSoundTouchSource +Selects the last SoundTouch source that was selected. + +**Response:** +```xml +/selectLastSoundTouchSource +``` + +#### GET /selectLastWiFiSource +Selects the last WiFi source that was selected. + +**Response:** +```xml +/selectLastWiFiSource +``` + +#### GET /selectLocalSource +Selects the LOCAL source (for devices where this is the only way to select LOCAL). + +**Response:** +```xml +/selectLocalSource +``` + +### Group Management (ST-10 Stereo Pairs Only) + +The ST-10 is the only SoundTouch product that supports stereo pair groups (different from zones). + +#### GET /getGroup +Gets current left/right stereo pair configuration. + +**Response XML:** +```xml + + Bose-ST10-1 + Bose-ST10-4 + 9070658C9D4A + + + 9070658C9D4A + LEFT + 192.168.1.131 + + + F45EAB3115DA + RIGHT + 192.168.1.134 + + + 192.168.1.131 + GROUP_OK + +``` + +#### POST /addGroup +Creates new left/right stereo pair speaker group. + +**Request XML:** +```xml + + Bose-ST10-1 + Bose-ST10-4 + 9070658C9D4A + + + 9070658C9D4A + LEFT + 192.168.1.131 + + + F45EAB3115DA + RIGHT + 192.168.1.134 + + + +``` + +**WebSocket Event:** `groupUpdated` sent to both devices + +#### GET /removeGroup +Removes existing stereo pair group. + +**Response:** +```xml + +``` + +#### POST /updateGroup +Updates name of stereo pair group. + +**Request XML:** +```xml + + Updated Group Name + 9070658C9D4A + + + 9070658C9D4A + LEFT + 192.168.1.131 + + + F45EAB3115DA + RIGHT + 192.168.1.134 + + + +``` + +### Advanced System Configuration + +#### GET /systemtimeout +Gets current system timeout configuration. + +**Response XML:** +```xml + + true + +``` + +#### GET /rebroadcastlatencymode +Gets current rebroadcast latency mode configuration. + +**Response XML:** +```xml + +``` + +#### GET /DSPMonoStereo +Gets current digital signal processor configuration. + +**Response XML:** +```xml + + + +``` + +## Implementation Notes + +### Device Compatibility +- Many endpoints work on specific device models only +- Always check `/supportedURLs` before implementing +- Test with real hardware when possible + +### Error Handling +- Services may return timeout errors on unsupported devices +- Some endpoints appear in `/supportedURLs` but still don't work +- Graceful degradation recommended + +### WebSocket Events +Many POST operations generate corresponding WebSocket events: +- `presetsUpdated` - Preset changes +- `groupUpdated` - Group changes +- `volumeUpdated` - Volume changes +- `nowPlayingUpdated` - Source/playback changes +- `zoneUpdated` - Zone changes + +### Security Considerations +- `/speaker` endpoint requires app_key parameter +- Token-based authentication available via `/requestToken` ✅ **Implemented** +- Some operations require device to be in specific states + +### Music Service Specifics +- Pandora: Confirmed working for station management, ratings +- Spotify: Requires PREMIUM account for most operations +- STORED_MUSIC: Requires UPnP/DLNA server setup +- LOCAL_MUSIC: Requires SoundTouch App Media Server running + +This documentation provides the foundation for implementing these endpoints in the Go library, with real-world examples and detailed XML structures verified against actual SoundTouch hardware. \ No newline at end of file diff --git a/pkg/client/client.go b/pkg/client/client.go index 1de7471..f373bf9 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -1274,6 +1274,18 @@ func (c *Client) RemoveZoneSlaveByDeviceID(masterDeviceID, slaveDeviceID string) return c.RemoveZoneSlave(masterDeviceID, slaveDeviceID, "") } +// RequestToken generates a new bearer token from the device +func (c *Client) RequestToken() (*models.BearerToken, error) { + var token models.BearerToken + + err := c.get("/requestToken", &token) + if err != nil { + return nil, fmt.Errorf("failed to request token: %w", err) + } + + return &token, nil +} + // hasCapability checks if a capability is present in the device capabilities func (c *Client) hasCapability(capabilities *models.Capabilities, capability string) bool { // Convert capabilities to string and check if it contains the capability diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 0f0b958..98dbb4d 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -1069,3 +1069,94 @@ func createTestClient(serverURL string) *Client { func contains(s, substr string) bool { return strings.Contains(s, substr) } + +func TestClient_RequestToken(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/requestToken" { + t.Errorf("Expected path '/requestToken', got '%s'", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + + return + } + + if r.Method != http.MethodGet { + t.Errorf("Expected GET method, got %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + + return + } + + // Return mock bearer token response (generic example) + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + })) + defer server.Close() + + // Create test client + client := createTestClient(server.URL) + + // Test RequestToken + token, err := client.RequestToken() + if err != nil { + t.Fatalf("RequestToken() failed: %v", err) + } + + if token == nil { + t.Fatal("RequestToken() returned nil token") + } + + // Verify token properties instead of exact values + if !token.IsValid() { + t.Error("Token should be valid") + } + + // Verify token has proper Bearer prefix + tokenValue := token.GetToken() + if !strings.HasPrefix(tokenValue, "Bearer ") { + t.Errorf("Token should start with 'Bearer ', got: %s", tokenValue) + } + + // Verify auth header matches full token + if token.GetAuthHeader() != tokenValue { + t.Errorf("Auth header should match token value") + } + + // Verify raw token extraction + rawToken := token.GetTokenWithoutPrefix() + if rawToken == tokenValue { + t.Error("Raw token should not include Bearer prefix") + } + + // Verify token is reasonably long (bearer tokens should be substantial) + if len(rawToken) < 50 { + t.Errorf("Token seems too short: %d characters", len(rawToken)) + } +} + +func TestClient_RequestToken_Error(t *testing.T) { + // Create mock server that returns error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Internal Server Error")) + })) + defer server.Close() + + // Create test client + client := createTestClient(server.URL) + + // Test RequestToken with error + token, err := client.RequestToken() + if err == nil { + t.Fatal("RequestToken() should have failed") + } + + if token != nil { + t.Error("RequestToken() should return nil token on error") + } + + if !strings.Contains(err.Error(), "failed to request token") { + t.Errorf("Error should mention 'failed to request token', got: %v", err) + } +} diff --git a/pkg/client/testdata/token_response.xml b/pkg/client/testdata/token_response.xml new file mode 100644 index 0000000..ada65dd --- /dev/null +++ b/pkg/client/testdata/token_response.xml @@ -0,0 +1 @@ + diff --git a/pkg/client/token_integration_test.go b/pkg/client/token_integration_test.go new file mode 100644 index 0000000..b90b0dc --- /dev/null +++ b/pkg/client/token_integration_test.go @@ -0,0 +1,95 @@ +package client + +import ( + "os" + "strings" + "testing" +) + +// TestRequestToken_Integration tests the RequestToken method against a real device +// This test only runs when SOUNDTOUCH_TEST_HOST environment variable is set +func TestRequestToken_Integration(t *testing.T) { + host := os.Getenv("SOUNDTOUCH_TEST_HOST") + if host == "" { + t.Skip("Skipping integration test: SOUNDTOUCH_TEST_HOST not set") + } + + // Create client for real device + config := &Config{ + Host: host, + Port: 8090, + } + client := NewClient(config) + + // Test RequestToken with real device + token, err := client.RequestToken() + if err != nil { + t.Fatalf("RequestToken() failed with real device: %v", err) + } + + if token == nil { + t.Fatal("RequestToken() returned nil token from real device") + } + + // Validate token properties without exposing actual values + tokenValue := token.GetToken() + + // Token should have Bearer prefix + if !strings.HasPrefix(tokenValue, "Bearer ") { + t.Error("Real device token should have 'Bearer ' prefix") + } + + // Token should be valid according to our validation + if !token.IsValid() { + t.Error("Real device token should be valid") + } + + // Raw token should not include Bearer prefix + rawToken := token.GetTokenWithoutPrefix() + if strings.HasPrefix(rawToken, "Bearer ") { + t.Error("Raw token should not include Bearer prefix") + } + + // Token should be reasonably long (real bearer tokens are substantial) + if len(rawToken) < 80 { + t.Errorf("Real device token seems too short: %d characters", len(rawToken)) + } + + // Token should only contain base64-like characters plus common token chars + validChars := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" + for _, char := range rawToken { + if !strings.ContainsRune(validChars, char) { + t.Errorf("Token contains unexpected character: %c", char) + } + } + + // Auth header should match full token value + if token.GetAuthHeader() != tokenValue { + t.Error("Auth header should match full token value") + } + + // String representation should be truncated for security + stringRepr := token.String() + if len(stringRepr) >= len(tokenValue) { + t.Error("String representation should be shorter than full token for security") + } + + // String representation should contain "..." for long tokens + if !strings.Contains(stringRepr, "...") { + t.Error("String representation should contain '...' for long tokens") + } + + // Multiple calls should generate different tokens (if the device supports it) + token2, err := client.RequestToken() + if err != nil { + t.Fatalf("Second RequestToken() call failed: %v", err) + } + + // Note: Some devices may return the same token, so we don't enforce uniqueness + // but we do verify the second token is also valid + if !token2.IsValid() { + t.Error("Second token should also be valid") + } + + t.Logf("Successfully validated real device token properties (length: %d chars)", len(rawToken)) +} diff --git a/pkg/models/clocktime.go b/pkg/models/clocktime.go index aff2a0c..de78183 100644 --- a/pkg/models/clocktime.go +++ b/pkg/models/clocktime.go @@ -8,15 +8,64 @@ import ( // ClockTime represents the device's system time type ClockTime struct { - XMLName xml.Name `xml:"clockTime"` - Zone string `xml:"zone,attr,omitempty"` - UTC int64 `xml:"utc,attr,omitempty"` - Value string `xml:",chardata"` + XMLName xml.Name `xml:"clockTime"` + UTCTime int64 `xml:"utcTime,attr,omitempty"` + CueMusic int `xml:"cueMusic,attr,omitempty"` + TimeFormat string `xml:"timeFormat,attr,omitempty"` + Brightness int `xml:"brightness,attr,omitempty"` + ClockError int `xml:"clockError,attr,omitempty"` + UTCSyncTime int64 `xml:"utcSyncTime,attr,omitempty"` + LocalTime *LocalTime `xml:"localTime,omitempty"` + Zone string `xml:"zone,attr,omitempty"` + UTC int64 `xml:"utc,attr,omitempty"` + Value string `xml:",chardata"` +} + +// LocalTime represents the local time component of ClockTime +type LocalTime struct { + XMLName xml.Name `xml:"localTime"` + Year int `xml:"year,attr"` + Month int `xml:"month,attr"` + DayOfMonth int `xml:"dayOfMonth,attr"` + DayOfWeek int `xml:"dayOfWeek,attr"` + Hour int `xml:"hour,attr"` + Minute int `xml:"minute,attr"` + Second int `xml:"second,attr"` } // GetTime returns the clock time as a time.Time object -// If UTC is provided, it uses that; otherwise tries to parse the Value +// Priority: LocalTime > UTCTime > UTC > Value func (c *ClockTime) GetTime() (time.Time, error) { + // Try LocalTime first (most accurate) + if c.LocalTime != nil { + // Note: Device returns month as 0-11, but Go expects 1-12 + month := c.LocalTime.Month + 1 + if month > 12 { + month = 12 + } + + if month < 1 { + month = 1 + } + + return time.Date( + c.LocalTime.Year, + time.Month(month), + c.LocalTime.DayOfMonth, + c.LocalTime.Hour, + c.LocalTime.Minute, + c.LocalTime.Second, + 0, + time.Local, + ), nil + } + + // Try UTCTime attribute + if c.UTCTime > 0 { + return time.Unix(c.UTCTime, 0), nil + } + + // Try legacy UTC attribute if c.UTC > 0 { return time.Unix(c.UTC, 0), nil } @@ -44,6 +93,10 @@ func (c *ClockTime) GetTime() (time.Time, error) { // GetUTC returns the UTC timestamp if available func (c *ClockTime) GetUTC() int64 { + if c.UTCTime > 0 { + return c.UTCTime + } + return c.UTC } @@ -52,6 +105,31 @@ func (c *ClockTime) GetZone() string { return c.Zone } +// GetTimeFormat returns the time format setting +func (c *ClockTime) GetTimeFormat() string { + return c.TimeFormat +} + +// GetBrightness returns the clock brightness setting +func (c *ClockTime) GetBrightness() int { + return c.Brightness +} + +// GetClockError returns the clock error status +func (c *ClockTime) GetClockError() int { + return c.ClockError +} + +// GetUTCSyncTime returns the UTC sync time +func (c *ClockTime) GetUTCSyncTime() int64 { + return c.UTCSyncTime +} + +// GetLocalTime returns the local time component +func (c *ClockTime) GetLocalTime() *LocalTime { + return c.LocalTime +} + // GetTimeString returns a formatted time string func (c *ClockTime) GetTimeString() string { if t, err := c.GetTime(); err == nil { @@ -63,21 +141,45 @@ func (c *ClockTime) GetTimeString() string { // IsEmpty returns true if the clock time has no data func (c *ClockTime) IsEmpty() bool { - return c.UTC == 0 && c.Value == "" + return c.UTCTime == 0 && c.UTC == 0 && c.Value == "" && c.LocalTime == nil } // SetTime sets the clock time from a time.Time object func (c *ClockTime) SetTime(t time.Time) { - c.UTC = t.Unix() + c.UTCTime = t.Unix() + c.UTC = t.Unix() // Keep for backward compatibility c.Value = t.UTC().Format("2006-01-02 15:04:05") c.Zone = t.Location().String() + + // Set LocalTime component + c.LocalTime = &LocalTime{ + Year: t.Year(), + Month: int(t.Month()) - 1, // Device expects 0-11 + DayOfMonth: t.Day(), + DayOfWeek: int(t.Weekday()), + Hour: t.Hour(), + Minute: t.Minute(), + Second: t.Second(), + } } // SetUTC sets the clock time from a UTC timestamp func (c *ClockTime) SetUTC(utc int64) { - c.UTC = utc + c.UTCTime = utc + c.UTC = utc // Keep for backward compatibility t := time.Unix(utc, 0).UTC() c.Value = t.Format("2006-01-02 15:04:05") + + // Set LocalTime component + c.LocalTime = &LocalTime{ + Year: t.Year(), + Month: int(t.Month()) - 1, // Device expects 0-11 + DayOfMonth: t.Day(), + DayOfWeek: int(t.Weekday()), + Hour: t.Hour(), + Minute: t.Minute(), + Second: t.Second(), + } } // ClockTimeRequest represents a request to set the device time diff --git a/pkg/models/token.go b/pkg/models/token.go new file mode 100644 index 0000000..a621e78 --- /dev/null +++ b/pkg/models/token.go @@ -0,0 +1,69 @@ +package models + +import ( + "encoding/xml" + "fmt" + "strings" +) + +// BearerToken represents a bearer token response from the device +type BearerToken struct { + XMLName xml.Name `xml:"bearertoken"` + Value string `xml:"value,attr"` +} + +// GetToken returns the bearer token value +func (b *BearerToken) GetToken() string { + return b.Value +} + +// GetTokenWithoutPrefix returns the bearer token value without the "Bearer " prefix +func (b *BearerToken) GetTokenWithoutPrefix() string { + token := b.Value + if strings.HasPrefix(token, "Bearer ") { + return token[7:] + } + + return token +} + +// IsValid returns true if the bearer token has a valid value +func (b *BearerToken) IsValid() bool { + return b.Value != "" && strings.HasPrefix(b.Value, "Bearer ") +} + +// String returns a string representation of the bearer token +func (b *BearerToken) String() string { + if !b.IsValid() { + return "Invalid bearer token" + } + + // Show only first and last 10 characters for security + token := b.GetTokenWithoutPrefix() + if len(token) > 20 { + return fmt.Sprintf("Bearer %s...%s", token[:10], token[len(token)-10:]) + } + + return b.Value +} + +// GetAuthHeader returns the token formatted for use in Authorization headers +func (b *BearerToken) GetAuthHeader() string { + if !b.IsValid() { + return "" + } + + return b.Value +} + +// NewBearerToken creates a new BearerToken with the given token value +func NewBearerToken(token string) *BearerToken { + // Ensure token has "Bearer " prefix + if !strings.HasPrefix(token, "Bearer ") { + token = "Bearer " + token + } + + return &BearerToken{ + Value: token, + } +} diff --git a/pkg/models/token_test.go b/pkg/models/token_test.go new file mode 100644 index 0000000..5449dba --- /dev/null +++ b/pkg/models/token_test.go @@ -0,0 +1,304 @@ +package models + +import ( + "encoding/xml" + "testing" +) + +func TestBearerToken_GetToken(t *testing.T) { + tests := []struct { + name string + token BearerToken + expected string + }{ + { + name: "Valid token", + token: BearerToken{Value: "Bearer abc123def456"}, + expected: "Bearer abc123def456", + }, + { + name: "Empty token", + token: BearerToken{Value: ""}, + expected: "", + }, + { + name: "Token without Bearer prefix", + token: BearerToken{Value: "abc123def456"}, + expected: "abc123def456", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.token.GetToken() + if result != tt.expected { + t.Errorf("GetToken() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestBearerToken_GetTokenWithoutPrefix(t *testing.T) { + tests := []struct { + name string + token BearerToken + expected string + }{ + { + name: "Valid token with Bearer prefix", + token: BearerToken{Value: "Bearer abc123def456"}, + expected: "abc123def456", + }, + { + name: "Token without Bearer prefix", + token: BearerToken{Value: "abc123def456"}, + expected: "abc123def456", + }, + { + name: "Token with Bearer and extra spaces", + token: BearerToken{Value: "Bearer abc123def456 "}, + expected: " abc123def456 ", + }, + { + name: "Empty token", + token: BearerToken{Value: ""}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.token.GetTokenWithoutPrefix() + if result != tt.expected { + t.Errorf("GetTokenWithoutPrefix() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestBearerToken_IsValid(t *testing.T) { + tests := []struct { + name string + token BearerToken + expected bool + }{ + { + name: "Valid token", + token: BearerToken{Value: "Bearer abc123def456"}, + expected: true, + }, + { + name: "Empty token", + token: BearerToken{Value: ""}, + expected: false, + }, + { + name: "Token without Bearer prefix", + token: BearerToken{Value: "abc123def456"}, + expected: false, + }, + { + name: "Just Bearer", + token: BearerToken{Value: "Bearer"}, + expected: false, + }, + { + name: "Bearer with space but no token", + token: BearerToken{Value: "Bearer "}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.token.IsValid() + if result != tt.expected { + t.Errorf("IsValid() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestBearerToken_String(t *testing.T) { + tests := []struct { + name string + token BearerToken + expected string + }{ + { + name: "Valid long token", + token: BearerToken{Value: "Bearer abcdefghij1234567890klmnopqrstuvwxyz"}, + expected: "Bearer abcdefghij...qrstuvwxyz", + }, + { + name: "Valid short token", + token: BearerToken{Value: "Bearer abc123"}, + expected: "Bearer abc123", + }, + { + name: "Invalid token", + token: BearerToken{Value: "abc123"}, + expected: "Invalid bearer token", + }, + { + name: "Empty token", + token: BearerToken{Value: ""}, + expected: "Invalid bearer token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.token.String() + if result != tt.expected { + t.Errorf("String() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestBearerToken_GetAuthHeader(t *testing.T) { + tests := []struct { + name string + token BearerToken + expected string + }{ + { + name: "Valid token", + token: BearerToken{Value: "Bearer abc123def456"}, + expected: "Bearer abc123def456", + }, + { + name: "Invalid token", + token: BearerToken{Value: "abc123"}, + expected: "", + }, + { + name: "Empty token", + token: BearerToken{Value: ""}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.token.GetAuthHeader() + if result != tt.expected { + t.Errorf("GetAuthHeader() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestNewBearerToken(t *testing.T) { + tests := []struct { + name string + input string + expected BearerToken + }{ + { + name: "Token with Bearer prefix", + input: "Bearer abc123def456", + expected: BearerToken{Value: "Bearer abc123def456"}, + }, + { + name: "Token without Bearer prefix", + input: "abc123def456", + expected: BearerToken{Value: "Bearer abc123def456"}, + }, + { + name: "Empty token", + input: "", + expected: BearerToken{Value: "Bearer "}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewBearerToken(tt.input) + if result.Value != tt.expected.Value { + t.Errorf("NewBearerToken() = %v, want %v", result.Value, tt.expected.Value) + } + }) + } +} + +func TestBearerToken_XMLMarshaling(t *testing.T) { + // Test unmarshaling from XML (device response) + xmlData := `` + + var token BearerToken + + err := xml.Unmarshal([]byte(xmlData), &token) + if err != nil { + t.Fatalf("Failed to unmarshal XML: %v", err) + } + + if token.Value != "Bearer abc123def456xyz789" { + t.Errorf("Unmarshaled token value = %v, want %v", token.Value, "Bearer abc123def456xyz789") + } + + if !token.IsValid() { + t.Error("Unmarshaled token should be valid") + } + + // Test marshaling to XML + token2 := BearerToken{Value: "Bearer test123token456"} + + data, err := xml.Marshal(token2) + if err != nil { + t.Fatalf("Failed to marshal to XML: %v", err) + } + + expected := `` + if string(data) != expected { + t.Errorf("Marshaled XML = %v, want %v", string(data), expected) + } +} + +func TestBearerToken_RealDeviceExample(t *testing.T) { + // Test with example device response format (generic token) + xmlData := `` + + var token BearerToken + + err := xml.Unmarshal([]byte(xmlData), &token) + if err != nil { + t.Fatalf("Failed to unmarshal device XML: %v", err) + } + + // Verify it's valid + if !token.IsValid() { + t.Error("Device token should be valid") + } + + // Verify auth header + authHeader := token.GetAuthHeader() + if authHeader != token.Value { + t.Errorf("Auth header = %v, want %v", authHeader, token.Value) + } + + // Verify string representation shows truncated version for long tokens + stringRepr := token.String() + if stringRepr == token.Value { + t.Error("String representation should be truncated for long tokens") + } + + // Should contain "Bearer" and "..." for long tokens + if len(stringRepr) >= len(token.Value) { + t.Error("String representation should be shorter than full token") + } + + // Verify raw token extraction + rawToken := token.GetTokenWithoutPrefix() + + expectedRawToken := "vUApzBVT6Lh0nw1xVu/plr1UDRNdMYMEpe0cStm4wCH5mWSjrrtORnGGirMn3pspkJ8mNR1MFh/J4OcsbEikMplcDGJVeuZOnDPAskQALvDBCF0PW74qXRms2k1AfLJ/" + if rawToken != expectedRawToken { + t.Errorf("Raw token = %v, want %v", rawToken, expectedRawToken) + } + + // Verify token length is reasonable (typical bearer tokens are long) + if len(rawToken) < 50 { + t.Errorf("Token seems too short: %d characters", len(rawToken)) + } +}