pynintendoparental 1.1.2__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,14 +6,20 @@ import asyncio
6
6
  from datetime import datetime, timedelta, time
7
7
  from typing import Callable
8
8
 
9
+ from pynintendoauth.exceptions import HttpException
10
+
9
11
  from .api import Api
10
12
  from .const import _LOGGER, DAYS_OF_WEEK
11
- from .exceptions import HttpException, BedtimeOutOfRangeError, DailyPlaytimeOutOfRangeError
12
- from .enum import AlarmSettingState, RestrictionMode
13
+ from .exceptions import (
14
+ BedtimeOutOfRangeError,
15
+ DailyPlaytimeOutOfRangeError,
16
+ )
17
+ from .enum import AlarmSettingState, DeviceTimerMode, RestrictionMode
13
18
  from .player import Player
14
19
  from .utils import is_awaitable
15
20
  from .application import Application
16
21
 
22
+
17
23
  class Device:
18
24
  """A device"""
19
25
 
@@ -28,7 +34,7 @@ class Device:
28
34
  self.parental_control_settings: dict = {}
29
35
  self.players: list[Player] = []
30
36
  self.limit_time: int | float | None = 0
31
- self.timer_mode: str = ""
37
+ self.timer_mode: DeviceTimerMode | None = None
32
38
  self.today_playing_time: int | float = 0
33
39
  self.today_time_remaining: int | float = 0
34
40
  self.bedtime_alarm: time | None = None
@@ -52,10 +58,7 @@ class Device:
52
58
  @property
53
59
  def model(self) -> str:
54
60
  """Return the model."""
55
- model_map = {
56
- "P00": "Switch",
57
- "P01": "Switch 2"
58
- }
61
+ model_map = {"P00": "Switch", "P01": "Switch 2"}
59
62
  return model_map.get(self.generation, "Unknown")
60
63
 
61
64
  @property
@@ -66,13 +69,14 @@ class Device:
66
69
  async def update(self):
67
70
  """Update data."""
68
71
  _LOGGER.debug(">> Device.update()")
72
+ now = datetime.now()
69
73
  await asyncio.gather(
70
- self._get_daily_summaries(),
71
- self._get_parental_control_setting(),
72
- self.get_monthly_summary(),
73
- self._get_extras()
74
+ self._get_daily_summaries(now),
75
+ self._get_parental_control_setting(now),
76
+ self.get_monthly_summary(),
77
+ self._get_extras(),
74
78
  )
75
- if self.players is None:
79
+ if not self.players:
76
80
  self.players = Player.from_device_daily_summary(self.daily_summaries)
77
81
  else:
78
82
  for player in self.players:
@@ -101,127 +105,173 @@ class Device:
101
105
  else:
102
106
  cb()
103
107
 
108
+ async def _send_api_update(self, api_call: Callable, *args, **kwargs):
109
+ """Sends an update to the API and refreshes local state."""
110
+ now = kwargs.pop("now", datetime.now())
111
+ response = await api_call(*args, **kwargs)
112
+ self._parse_parental_control_setting(response["json"], now)
113
+ self._calculate_times(now)
114
+ await self._execute_callbacks()
115
+
104
116
  async def set_new_pin(self, pin: str):
105
117
  """Updates the pin for the device."""
106
118
  _LOGGER.debug(">> Device.set_new_pin(pin=REDACTED)")
107
- self.parental_control_settings["unlockCode"] = pin
108
- response = await self._api.async_update_unlock_code(
109
- new_code=pin,
110
- device_id=self.device_id
119
+ await self._send_api_update(
120
+ self._api.async_update_unlock_code, new_code=pin, device_id=self.device_id
111
121
  )
112
- self._parse_parental_control_setting(response["json"])
113
- await self._execute_callbacks()
114
122
 
115
123
  async def add_extra_time(self, minutes: int):
116
124
  """Add extra time to the device."""
117
125
  _LOGGER.debug(">> Device.add_extra_time(minutes=%s)", minutes)
118
- await self._api.async_update_extra_playing_time(
119
- device_id=self.device_id,
120
- additional_time=minutes
121
- )
122
- await self._execute_callbacks()
123
-
126
+ # This endpoint does not return parental control settings, so we call it directly.
127
+ await self._api.async_update_extra_playing_time(self.device_id, minutes)
128
+ await self._get_parental_control_setting(datetime.now())
124
129
 
125
130
  async def set_restriction_mode(self, mode: RestrictionMode):
126
131
  """Updates the restriction mode of the device."""
127
132
  _LOGGER.debug(">> Device.set_restriction_mode(mode=%s)", mode)
128
- self.parental_control_settings["playTimerRegulations"]["restrictionMode"] = str(mode)
133
+ self.parental_control_settings["playTimerRegulations"]["restrictionMode"] = str(
134
+ mode
135
+ )
129
136
  response = await self._api.async_update_play_timer(
130
137
  settings={
131
138
  "deviceId": self.device_id,
132
- "playTimerRegulations": self.parental_control_settings["playTimerRegulations"]
139
+ "playTimerRegulations": self.parental_control_settings[
140
+ "playTimerRegulations"
141
+ ],
133
142
  }
134
143
  )
135
- self._parse_parental_control_setting(response["json"])
144
+ now = datetime.now()
145
+ self._parse_parental_control_setting(
146
+ response["json"], now
147
+ ) # Don't need to recalculate times
136
148
  await self._execute_callbacks()
137
149
 
138
150
  async def set_bedtime_alarm(self, value: time):
139
151
  """Update the bedtime alarm for the device."""
140
- _LOGGER.debug(">> Device.set_bedtime_alarm(value=%s)",
141
- value)
152
+ _LOGGER.debug(">> Device.set_bedtime_alarm(value=%s)", value)
142
153
  if not (
143
- (16 <= value.hour <= 22) or
144
- (value.hour == 23 and value.minute == 0) or
145
- (value.hour == 0 and value.minute == 0)
154
+ (16 <= value.hour <= 22)
155
+ or (value.hour == 23 and value.minute == 0)
156
+ or (value.hour == 0 and value.minute == 0)
146
157
  ):
147
158
  raise BedtimeOutOfRangeError(value=value)
159
+ now = datetime.now()
148
160
  bedtime = {
149
161
  "enabled": value.hour != 0 and value.minute != 0,
150
162
  }
151
163
  if bedtime["enabled"]:
152
164
  bedtime = {
153
165
  **bedtime,
154
- "endingTime": {
155
- "hour": value.hour,
156
- "minute": value.minute
157
- }
166
+ "endingTime": {"hour": value.hour, "minute": value.minute},
158
167
  }
159
- if self.timer_mode == "DAILY":
160
- self.parental_control_settings["playTimerRegulations"]["dailyRegulations"]["bedtime"] = bedtime
168
+ if self.timer_mode == DeviceTimerMode.DAILY:
169
+ self.parental_control_settings["playTimerRegulations"]["dailyRegulations"][
170
+ "bedtime"
171
+ ] = bedtime
161
172
  else:
162
- self.parental_control_settings["playTimerRegulations"]["eachDayOfTheWeekRegulations"][
163
- DAYS_OF_WEEK[datetime.now().weekday()]
164
- ]["bedtime"] = bedtime
165
- response = await self._api.async_update_play_timer(
173
+ self.parental_control_settings["playTimerRegulations"][
174
+ "eachDayOfTheWeekRegulations"
175
+ ][DAYS_OF_WEEK[now.weekday()]]["bedtime"] = bedtime
176
+ await self._send_api_update(
177
+ self._api.async_update_play_timer,
166
178
  settings={
167
179
  "deviceId": self.device_id,
168
- "playTimerRegulations": self.parental_control_settings["playTimerRegulations"]
169
- }
180
+ "playTimerRegulations": self.parental_control_settings[
181
+ "playTimerRegulations"
182
+ ],
183
+ },
184
+ now=now,
185
+ )
186
+
187
+ async def set_timer_mode(self, mode: DeviceTimerMode):
188
+ """Updates the timer mode of the device."""
189
+ _LOGGER.debug(">> Device.set_timer_mode(mode=%s)", mode)
190
+ self.timer_mode = mode
191
+ self.parental_control_settings["playTimerRegulations"]["timerMode"] = str(mode)
192
+ await self._send_api_update(
193
+ self._api.async_update_play_timer,
194
+ settings={
195
+ "deviceId": self.device_id,
196
+ "playTimerRegulations": self.parental_control_settings[
197
+ "playTimerRegulations"
198
+ ],
199
+ },
170
200
  )
171
- self._parse_parental_control_setting(response["json"])
172
- self._calculate_times()
173
- await self._execute_callbacks()
174
201
 
175
202
  async def update_max_daily_playtime(self, minutes: int | float = 0):
176
203
  """Updates the maximum daily playtime of a device."""
177
- _LOGGER.debug(">> Device.update_max_daily_playtime(minutes=%s)",
178
- minutes)
204
+ _LOGGER.debug(">> Device.update_max_daily_playtime(minutes=%s)", minutes)
179
205
  if isinstance(minutes, float):
180
206
  minutes = int(minutes)
181
207
  if minutes > 360 or minutes < -1:
182
208
  raise DailyPlaytimeOutOfRangeError(minutes)
209
+ now = datetime.now()
183
210
  ttpiod = True
184
211
  if minutes == -1:
185
212
  ttpiod = False
186
213
  minutes = None
187
- if self.timer_mode == "DAILY":
214
+ if self.timer_mode == DeviceTimerMode.DAILY:
188
215
  _LOGGER.debug(
189
216
  "Setting timeToPlayInOneDay.limitTime for device %s to value %s",
190
217
  self.device_id,
191
- minutes)
192
- self.parental_control_settings["playTimerRegulations"]["dailyRegulations"]["timeToPlayInOneDay"]["enabled"] = ttpiod
193
- if "limitTime" in self.parental_control_settings["playTimerRegulations"]["dailyRegulations"]["timeToPlayInOneDay"] and minutes is None:
194
- self.parental_control_settings["playTimerRegulations"]["dailyRegulations"]["timeToPlayInOneDay"].pop("limitTime")
218
+ minutes,
219
+ )
220
+ self.parental_control_settings["playTimerRegulations"]["dailyRegulations"][
221
+ "timeToPlayInOneDay"
222
+ ]["enabled"] = ttpiod
223
+ if (
224
+ "limitTime"
225
+ in self.parental_control_settings["playTimerRegulations"][
226
+ "dailyRegulations"
227
+ ]["timeToPlayInOneDay"]
228
+ and minutes is None
229
+ ):
230
+ self.parental_control_settings["playTimerRegulations"][
231
+ "dailyRegulations"
232
+ ]["timeToPlayInOneDay"].pop("limitTime")
195
233
  else:
196
- self.parental_control_settings["playTimerRegulations"]["dailyRegulations"]["timeToPlayInOneDay"]["limitTime"] = minutes
234
+ self.parental_control_settings["playTimerRegulations"][
235
+ "dailyRegulations"
236
+ ]["timeToPlayInOneDay"]["limitTime"] = minutes
197
237
  else:
198
238
  _LOGGER.debug(
199
239
  "Setting timeToPlayInOneDay.limitTime for device %s to value %s",
200
240
  self.device_id,
201
- minutes
241
+ minutes,
202
242
  )
203
- day_of_week_regs = self.parental_control_settings["playTimerRegulations"]["eachDayOfTheWeekRegulations"]
204
- current_day = DAYS_OF_WEEK[datetime.now().weekday()]
243
+ day_of_week_regs = self.parental_control_settings["playTimerRegulations"][
244
+ "eachDayOfTheWeekRegulations"
245
+ ]
246
+ current_day = DAYS_OF_WEEK[now.weekday()]
205
247
  day_of_week_regs[current_day]["timeToPlayInOneDay"]["enabled"] = ttpiod
206
- if "limitTime" in day_of_week_regs[current_day]["timeToPlayInOneDay"] and minutes is None:
248
+ if (
249
+ "limitTime" in day_of_week_regs[current_day]["timeToPlayInOneDay"]
250
+ and minutes is None
251
+ ):
207
252
  day_of_week_regs[current_day]["timeToPlayInOneDay"].pop("limitTime")
208
253
  else:
209
- day_of_week_regs[current_day]["timeToPlayInOneDay"]["limitTime"] = minutes
254
+ day_of_week_regs[current_day]["timeToPlayInOneDay"]["limitTime"] = (
255
+ minutes
256
+ )
210
257
 
211
- response = await self._api.async_update_play_timer(
258
+ await self._send_api_update(
259
+ self._api.async_update_play_timer,
212
260
  settings={
213
261
  "deviceId": self.device_id,
214
- "playTimerRegulations": self.parental_control_settings["playTimerRegulations"]
215
- }
262
+ "playTimerRegulations": self.parental_control_settings[
263
+ "playTimerRegulations"
264
+ ],
265
+ },
266
+ now=now,
216
267
  )
217
- self._parse_parental_control_setting(response["json"])
218
- self._calculate_times()
219
- await self._execute_callbacks()
220
268
 
221
269
  def _update_applications(self):
222
270
  """Updates applications from daily summary."""
223
271
  _LOGGER.debug(">> Device._update_applications()")
224
- parsed_apps = Application.from_whitelist(self.parental_control_settings.get("whitelistedApplications", []))
272
+ parsed_apps = Application.from_whitelist(
273
+ self.parental_control_settings.get("whitelistedApplications", [])
274
+ )
225
275
  for app in parsed_apps:
226
276
  try:
227
277
  self.get_application(app.application_id).update(app)
@@ -229,112 +279,87 @@ class Device:
229
279
  except ValueError:
230
280
  self.applications.append(app)
231
281
 
232
- def _update_day_of_week_regulations(self):
233
- """Override the limit / bed time for the device from parental_control_settings if individual days are configured."""
234
- day_of_week_regs = self.parental_control_settings["playTimerRegulations"].get("eachDayOfTheWeekRegulations", {})
235
- current_day = day_of_week_regs.get(DAYS_OF_WEEK[datetime.now().weekday()], {})
236
- self.timer_mode = self.parental_control_settings["playTimerRegulations"]["timerMode"]
237
- if self.timer_mode == "EACH_DAY_OF_THE_WEEK":
238
- regulations = current_day
239
- else:
240
- regulations = self.parental_control_settings.get("playTimerRegulations", {}).get("dailyRegulations", {})
241
-
242
- limit_time = regulations.get("timeToPlayInOneDay", {}).get("limitTime")
243
- self.limit_time = limit_time if limit_time is not None else -1
244
-
245
- if self.timer_mode == "EACH_DAY_OF_THE_WEEK":
246
- if current_day["bedtime"]["enabled"]:
247
- self.bedtime_alarm = time(hour=
248
- current_day["bedtime"]["endingTime"]["hour"],
249
- minute=current_day["bedtime"]["endingTime"]["minute"])
250
- else:
251
- self.bedtime_alarm = time(hour=0, minute=0)
252
- else:
253
- bedtime_alarm = self.parental_control_settings["playTimerRegulations"]["dailyRegulations"]["bedtime"]
254
- if bedtime_alarm["enabled"]:
255
- self.bedtime_alarm = time(hour=
256
- bedtime_alarm["endingTime"]["hour"],
257
- minute=bedtime_alarm["endingTime"]["minute"])
258
- else:
259
- self.bedtime_alarm = time(hour=0, minute=0)
260
- return True
282
+ def _get_today_regulation(self, now: datetime) -> dict:
283
+ """Returns the regulation settings for the current day."""
284
+ if self.timer_mode == DeviceTimerMode.EACH_DAY_OF_THE_WEEK:
285
+ day_of_week_regs = self.parental_control_settings[
286
+ "playTimerRegulations"
287
+ ].get("eachDayOfTheWeekRegulations", {})
288
+ return day_of_week_regs.get(DAYS_OF_WEEK[now.weekday()], {})
289
+ return self.parental_control_settings.get("playTimerRegulations", {}).get(
290
+ "dailyRegulations", {}
291
+ )
261
292
 
262
- def _parse_parental_control_setting(self, pcs: dict):
293
+ def _parse_parental_control_setting(self, pcs: dict, now: datetime):
263
294
  """Parse a parental control setting request response."""
264
295
  _LOGGER.debug(">> Device._parse_parental_control_setting()")
265
296
  self.parental_control_settings = pcs["parentalControlSetting"]
266
- if "bedtimeStartingTime" in self.parental_control_settings["playTimerRegulations"]:
267
- if self.parental_control_settings["playTimerRegulations"].get("bedtimeStartingTime", {}).get("hour", 0) == 0:
268
- self.parental_control_settings["playTimerRegulations"].pop("bedtimeStartingTime")
269
- self.forced_termination_mode = (
270
- self.parental_control_settings["playTimerRegulations"]["restrictionMode"] == str(RestrictionMode.FORCED_TERMINATION)
297
+
298
+ # Clean up bedtimeStartingTime if it's empty
299
+ if (
300
+ "bedtimeStartingTime"
301
+ in self.parental_control_settings["playTimerRegulations"]
302
+ ):
303
+ if (
304
+ self.parental_control_settings["playTimerRegulations"]
305
+ .get("bedtimeStartingTime", {})
306
+ .get("hour", 0)
307
+ == 0
308
+ ):
309
+ self.parental_control_settings["playTimerRegulations"].pop(
310
+ "bedtimeStartingTime"
311
+ )
312
+
313
+ self.forced_termination_mode = self.parental_control_settings[
314
+ "playTimerRegulations"
315
+ ]["restrictionMode"] == str(RestrictionMode.FORCED_TERMINATION)
316
+
317
+ # Update limit and bedtime from regulations
318
+ self.timer_mode = DeviceTimerMode(
319
+ self.parental_control_settings["playTimerRegulations"]["timerMode"]
271
320
  )
272
- self._update_day_of_week_regulations()
321
+ today_reg = self._get_today_regulation(now)
322
+ limit_time = today_reg.get("timeToPlayInOneDay", {}).get("limitTime")
323
+ self.limit_time = limit_time if limit_time is not None else -1
324
+
325
+ bedtime_setting = today_reg.get("bedtime", {})
326
+ if bedtime_setting.get("enabled"):
327
+ self.bedtime_alarm = time(
328
+ hour=bedtime_setting["endingTime"]["hour"],
329
+ minute=bedtime_setting["endingTime"]["minute"],
330
+ )
331
+ else:
332
+ self.bedtime_alarm = time(hour=0, minute=0)
333
+
273
334
  self._update_applications()
274
335
 
275
- def _calculate_times(self):
336
+ def _calculate_times(self, now: datetime):
276
337
  """Calculate times from parental control settings."""
277
338
  if not isinstance(self.daily_summaries, list) or not self.daily_summaries:
278
339
  return
279
340
  if len(self.daily_summaries) == 0:
280
341
  return
281
342
  _LOGGER.debug(">> Device._calculate_times()")
282
- today_playing_time = self.daily_summaries[0].get("playingTime", 0)
283
- self.today_playing_time = 0 if today_playing_time is None else today_playing_time
284
- today_disabled_time = self.daily_summaries[0].get("disabledTime", 0)
285
- self.today_disabled_time = 0 if today_disabled_time is None else today_disabled_time
286
- today_exceeded_time = self.daily_summaries[0].get("exceededTime", 0)
287
- self.today_exceeded_time = 0 if today_exceeded_time is None else today_exceeded_time
288
- _LOGGER.debug("Cached playing, disabled and exceeded time for today for device %s",
289
- self.device_id)
290
- try:
291
- now = datetime.now()
292
- current_minutes_past_midnight = now.hour * 60 + now.minute
293
- minutes_in_day = 1440 # 24 * 60
294
-
295
- # 1. Calculate remaining time based on play limit
296
-
297
- time_remaining_by_play_limit = 0.0
298
- if self.limit_time in (-1, None):
299
- # No specific play limit, effectively limited by end of day for this calculation step.
300
- time_remaining_by_play_limit = float(minutes_in_day - current_minutes_past_midnight)
301
- elif self.limit_time == 0:
302
- time_remaining_by_play_limit = 0.0
303
- else:
304
- time_remaining_by_play_limit = float(self.limit_time - self.today_playing_time)
305
-
306
- time_remaining_by_play_limit = max(0.0, time_remaining_by_play_limit)
307
-
308
- # Initialize overall remaining time with play limit constraint
309
- effective_remaining_time = time_remaining_by_play_limit
310
-
311
- # 2. Factor in bedtime alarm, if any, to further constrain remaining time
312
- if self.bedtime_alarm not in (None, time(hour=0, minute=0)):
313
- bedtime_dt = datetime.combine(now.date(), self.bedtime_alarm)
314
- time_remaining_by_bedtime = 0.0
315
- if bedtime_dt > now: # Bedtime is in the future today
316
- time_remaining_by_bedtime = (bedtime_dt - now).total_seconds() / 60
317
- time_remaining_by_bedtime = max(0.0, time_remaining_by_bedtime)
318
- # else: Bedtime has passed for today or is now, so time_remaining_by_bedtime remains 0.0
319
-
320
- effective_remaining_time = min(effective_remaining_time, time_remaining_by_bedtime)
321
-
322
- self.today_time_remaining = int(max(0.0, effective_remaining_time)) # Ensure non-negative and integer
323
- _LOGGER.debug("Calculated and updated the amount of time remaining for today: %s", self.today_time_remaining)
324
- self.stats_update_failed = False
325
- except ValueError as err:
326
- _LOGGER.debug("Unable to update daily summary for device %s: %s", self.name, err)
327
- self.stats_update_failed = True
343
+ if self.daily_summaries[0]["date"] != now.strftime("%Y-%m-%d"):
344
+ _LOGGER.debug("No daily summary for today, assuming 0 playing time.")
345
+ self.today_playing_time = 0
346
+ self.today_disabled_time = 0
347
+ self.today_exceeded_time = 0
348
+ else:
349
+ self.today_playing_time = self.daily_summaries[0].get("playingTime") or 0
350
+ self.today_disabled_time = self.daily_summaries[0].get("disabledTime") or 0
351
+ self.today_exceeded_time = self.daily_summaries[0].get("exceededTime") or 0
352
+ _LOGGER.debug(
353
+ "Cached playing, disabled and exceeded time for today for device %s",
354
+ self.device_id,
355
+ )
356
+ self._calculate_today_remaining_time(now)
328
357
 
329
- current_month = datetime(
330
- year=datetime.now().year,
331
- month=datetime.now().month,
332
- day=1)
333
358
  month_playing_time: int = 0
334
359
 
335
360
  for summary in self.daily_summaries:
336
361
  date_parsed = datetime.strptime(summary["date"], "%Y-%m-%d")
337
- if date_parsed > current_month:
362
+ if date_parsed.year == now.year and date_parsed.month == now.month:
338
363
  month_playing_time += summary["playingTime"]
339
364
  self.month_playing_time = month_playing_time
340
365
  _LOGGER.debug("Cached current month playing time for device %s", self.device_id)
@@ -342,44 +367,98 @@ class Device:
342
367
  for app in parsed_apps:
343
368
  try:
344
369
  int_app = self.get_application(app.application_id)
345
- _LOGGER.debug("Updating cached app state %s for device %s",
346
- int_app.application_id,
347
- self.device_id)
370
+ _LOGGER.debug(
371
+ "Updating cached app state %s for device %s",
372
+ int_app.application_id,
373
+ self.device_id,
374
+ )
348
375
  int_app.update(app)
349
376
  except ValueError:
350
- _LOGGER.debug("Creating new cached application entry %s for device %s",
351
- app.application_id,
352
- self.device_id)
377
+ _LOGGER.debug(
378
+ "Creating new cached application entry %s for device %s",
379
+ app.application_id,
380
+ self.device_id,
381
+ )
353
382
  self.applications.append(app)
354
383
 
355
384
  # update application playtime
356
385
  try:
357
386
  for player in self.get_date_summary()[0].get("devicePlayers", []):
358
387
  for app in player.get("playedApps", []):
359
- self.get_application(app["applicationId"]).update_today_time_played(app)
388
+ self.get_application(app["applicationId"]).update_today_time_played(
389
+ app
390
+ )
360
391
  self.application_update_failed = False
361
392
  except ValueError as err:
362
- _LOGGER.debug("Unable to retrieve applications for device %s: %s", self.name, err)
393
+ _LOGGER.debug(
394
+ "Unable to retrieve applications for device %s: %s", self.name, err
395
+ )
363
396
  self.application_update_failed = True
364
397
 
365
- async def _get_parental_control_setting(self):
398
+ def _calculate_today_remaining_time(self, now: datetime):
399
+ """Calculates the remaining playing time for today."""
400
+ self.stats_update_failed = True # Assume failure until success
401
+ try:
402
+ minutes_in_day = 1440 # 24 * 60
403
+ current_minutes_past_midnight = now.hour * 60 + now.minute
404
+
405
+ if self.limit_time in (-1, None):
406
+ # No play limit, so remaining time is until end of day.
407
+ time_remaining_by_play_limit = (
408
+ minutes_in_day - current_minutes_past_midnight
409
+ )
410
+ else:
411
+ time_remaining_by_play_limit = self.limit_time - self.today_playing_time
412
+
413
+ # 2. Calculate remaining time until bedtime
414
+ if (
415
+ self.bedtime_alarm
416
+ and self.bedtime_alarm != time(hour=0, minute=0)
417
+ and self.alarms_enabled
418
+ ):
419
+ bedtime_dt = datetime.combine(now.date(), self.bedtime_alarm)
420
+ if bedtime_dt > now: # Bedtime is in the future today
421
+ time_remaining_by_bedtime = (bedtime_dt - now).total_seconds() / 60
422
+ else: # Bedtime has passed
423
+ time_remaining_by_bedtime = 0.0
424
+ else:
425
+ time_remaining_by_bedtime = (
426
+ minutes_in_day - current_minutes_past_midnight
427
+ )
428
+
429
+ # Effective remaining time is the minimum of the two constraints
430
+ effective_remaining_time = min(
431
+ time_remaining_by_play_limit, time_remaining_by_bedtime
432
+ )
433
+ self.today_time_remaining = int(max(0.0, effective_remaining_time))
434
+ _LOGGER.debug(
435
+ "Calculated today's remaining time: %s minutes",
436
+ self.today_time_remaining,
437
+ )
438
+ self.stats_update_failed = False
439
+ except (ValueError, TypeError, AttributeError) as err:
440
+ _LOGGER.warning(
441
+ "Unable to calculate remaining time for device %s: %s", self.name, err
442
+ )
443
+
444
+ async def _get_parental_control_setting(self, now: datetime):
366
445
  """Retreives parental control settings from the API."""
367
446
  _LOGGER.debug(">> Device._get_parental_control_setting()")
368
447
  response = await self._api.async_get_device_parental_control_setting(
369
448
  device_id=self.device_id
370
449
  )
371
- self._parse_parental_control_setting(response["json"])
372
- self._calculate_times()
450
+ self._parse_parental_control_setting(response["json"], now)
451
+ self._calculate_times(now)
373
452
 
374
- async def _get_daily_summaries(self):
453
+ async def _get_daily_summaries(self, now: datetime):
375
454
  """Retrieve daily summaries."""
376
455
  _LOGGER.debug(">> Device._get_daily_summaries()")
377
456
  response = await self._api.async_get_device_daily_summaries(
378
- device_id = self.device_id
457
+ device_id=self.device_id
379
458
  )
380
459
  self.daily_summaries = response["json"]["dailySummaries"]
381
460
  _LOGGER.debug("New daily summary %s", self.daily_summaries)
382
- self._calculate_times()
461
+ self._calculate_times(now)
383
462
 
384
463
  async def _get_extras(self):
385
464
  """Retrieve extra properties."""
@@ -387,14 +466,16 @@ class Device:
387
466
  if self.alarms_enabled is not None:
388
467
  # first refresh can come from self.extra without http request
389
468
  response = await self._api.async_get_account_device(
390
- device_id = self.device_id
469
+ device_id=self.device_id
391
470
  )
392
471
  self.extra = response["json"]["ownedDevice"]["device"]
393
472
  status = self.extra["alarmSetting"]["visibility"]
394
473
  self.alarms_enabled = status == str(AlarmSettingState.VISIBLE)
395
- _LOGGER.debug("Cached alarms enabled to state %s for device %s",
396
- self.alarms_enabled,
397
- self.device_id)
474
+ _LOGGER.debug(
475
+ "Cached alarms enabled to state %s for device %s",
476
+ self.alarms_enabled,
477
+ self.device_id,
478
+ )
398
479
 
399
480
  async def get_monthly_summary(self, search_date: datetime = None) -> dict | None:
400
481
  """Gets the monthly summary."""
@@ -412,69 +493,84 @@ class Device:
412
493
  available_summaries = response["json"]["available"]
413
494
  _LOGGER.debug("Available monthly summaries: %s", available_summaries)
414
495
  if not available_summaries:
415
- _LOGGER.debug("No monthly summaries available for device %s", self.device_id)
496
+ _LOGGER.debug(
497
+ "No monthly summaries available for device %s", self.device_id
498
+ )
416
499
  return None
417
500
  # Use the most recent available summary
418
501
  available_summary = available_summaries[0]
419
- search_date = datetime.strptime(f"{available_summary['year']}-{available_summary['month']}-01", "%Y-%m-%d")
420
- _LOGGER.debug("Using search date %s for monthly summary request", search_date)
502
+ search_date = datetime.strptime(
503
+ f"{available_summary['year']}-{available_summary['month']}-01",
504
+ "%Y-%m-%d",
505
+ )
506
+ _LOGGER.debug(
507
+ "Using search date %s for monthly summary request", search_date
508
+ )
421
509
  latest = True
422
510
 
423
511
  try:
424
512
  response = await self._api.async_get_device_monthly_summary(
425
- device_id=self.device_id,
426
- year=search_date.year,
427
- month=search_date.month
513
+ device_id=self.device_id, year=search_date.year, month=search_date.month
428
514
  )
429
515
  except HttpException as exc:
430
- _LOGGER.warning("HTTP Exception raised while getting monthly summary for device %s: %s",
431
- self.device_id,
432
- exc)
516
+ _LOGGER.warning(
517
+ "HTTP Exception raised while getting monthly summary for device %s: %s",
518
+ self.device_id,
519
+ exc,
520
+ )
433
521
  return None
434
522
  else:
435
- _LOGGER.debug("Monthly summary query complete for device %s: %s",
436
- self.device_id,
437
- response["json"]["summary"])
523
+ _LOGGER.debug(
524
+ "Monthly summary query complete for device %s: %s",
525
+ self.device_id,
526
+ response["json"]["summary"],
527
+ )
438
528
  if latest:
439
529
  self.last_month_summary = summary = response["json"]["summary"]
440
530
  return summary
441
531
  return response["json"]["summary"]
442
532
 
443
-
444
533
  def get_date_summary(self, input_date: datetime = datetime.now()) -> dict:
445
534
  """Returns usage for a given date."""
535
+ if not self.daily_summaries:
536
+ raise ValueError("No daily summaries available to search.")
446
537
  summary = [
447
- x for x in self.daily_summaries
448
- if x["date"] == input_date.strftime('%Y-%m-%d')
538
+ x
539
+ for x in self.daily_summaries
540
+ if x["date"] == input_date.strftime("%Y-%m-%d")
449
541
  ]
450
542
  if len(summary) == 0:
451
543
  input_date -= timedelta(days=1)
452
544
  summary = [
453
- x for x in self.daily_summaries
454
- if x["date"] == input_date.strftime('%Y-%m-%d')
455
- ]
545
+ x
546
+ for x in self.daily_summaries
547
+ if x["date"] == input_date.strftime("%Y-%m-%d")
548
+ ]
456
549
  if len(summary) == 0:
457
- raise ValueError(f"A summary for the given date {input_date} does not exist")
550
+ raise ValueError(
551
+ f"A summary for the given date {input_date} does not exist"
552
+ )
458
553
  return summary
459
554
 
460
555
  def get_application(self, application_id: str) -> Application:
461
556
  """Returns a single application."""
462
- app = [x for x in self.applications
463
- if x.application_id == application_id]
464
- if len(app) == 1:
465
- return app[0]
466
- raise ValueError("Application not found.")
557
+ app = next(
558
+ (app for app in self.applications if app.application_id == application_id),
559
+ None,
560
+ )
561
+ if app:
562
+ return app
563
+ raise ValueError(f"Application with id {application_id} not found.")
467
564
 
468
565
  def get_player(self, player_id: str) -> Player:
469
566
  """Returns a player."""
470
- player = [x for x in self.players
471
- if x.player_id == player_id]
472
- if len(player) == 1:
473
- return player[0]
474
- raise ValueError("Player not found.")
567
+ player = next((p for p in self.players if p.player_id == player_id), None)
568
+ if player:
569
+ return player
570
+ raise ValueError(f"Player with id {player_id} not found.")
475
571
 
476
572
  @classmethod
477
- async def from_devices_response(cls, raw: dict, api) -> list['Device']:
573
+ async def from_devices_response(cls, raw: dict, api) -> list["Device"]:
478
574
  """Parses a device request response body."""
479
575
  _LOGGER.debug("Parsing device list response")
480
576
  if "ownedDevices" not in raw.keys():
@@ -492,7 +588,7 @@ class Device:
492
588
  return devices
493
589
 
494
590
  @classmethod
495
- def from_device_response(cls, raw: dict, api) -> 'Device':
591
+ def from_device_response(cls, raw: dict, api) -> "Device":
496
592
  """Parses a single device request response body."""
497
593
  _LOGGER.debug("Parsing device response")
498
594
  if "deviceId" not in raw.keys():