Coverage for apps/shopping_list.py: 100%

56 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-03-12 12:23 +0000

1# The MIT License (MIT) 

2# 

3# Copyright © 2023 Xavier Berger 

4# 

5# Permission is hereby granted, free of charge, to any person obtaining a copy of this software 

6# and associated documentation files (the “Software”), to deal in the Software without restriction, 

7# including without limitation the rights to use, copy, modify, merge, publish, distribute, 

8# sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 

9# furnished to do so, subject to the following conditions: 

10# 

11# The above copyright notice and this permission notice shall be included in all copies or 

12# substantial portions of the Software. 

13# 

14# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 

15# NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 

16# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 

17# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 

18# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 

19import json 

20import shutil 

21import time 

22 

23import appdaemon.plugins.hass.hassapi as hass 

24 

25# ---------------------------------------------------------------------------------------------------------------------- 

26# Multiple shopping list manager 

27# ---------------------------------------------------------------------------------------------------------------------- 

28# 

29# Manage multiple shopping and notification based on Zone. 

30# Notification gives an access to shopping list when entered into a shop. 

31# 

32# ---------------------------------------------------------------------------------------------------------------------- 

33# 

34# To configure the application follow the instruction below: 

35# 

36# Create an input_select gathering the list of shops. 

37# Options of this input_select are used to select the active list. 

38# 

39# Create zones 

40# Zone are used to define shops area. These shop area are used to automatically select active list and trigger 

41# notification. The beginning of zone's friendly_name has to match the shop name as defined into options of 

42# input_select described upper. 

43# 

44# Example: 

45# Zone "zone.Biocoop_Grenoble" and "zone.Biocoop_Modane" will both use the shoppinglist named "Biocoop" 

46# 

47# Notifier is a dependency 

48# Refer to notifier.py documentation activate notification 

49# 

50# Configure an AppDeamon application with: 

51# shopping_list: 

52# module: shopping_list 

53# class : ShoppingList 

54# shops: input_select gathering the shops to manage 

55# tempo: delay ins seconds between list population and item complete update (recommended: 0.1) 

56# if complete item are not set corectly, increase this value 

57# notificationurl: url of shopping list's lovelace card used in notification 

58# notification_title: title display in notification. This text will be prefixed by the zone name. 

59# notification_message: message to display in notification 

60# persons: List of person to notify when they enter into shop zone. At least one person has to be defined. 

61# - name: username as defined in notifier application (used for notification) 

62# id: a user as defined in notifier application (used for zone tracking) 

63# 

64# Appdaemon configuration example: 

65# shopping_list: 

66# module: shopping_list 

67# class: ShoppingList 

68# log: shopping_list_log 

69# shops: input_select.shoppinglist 

70# tempo: 0.1 

71# notification_url: "/shopping-list-extended/" 

72# notification_title: "Shopping list" 

73# notification_message: "Show shopping list" 

74# persons: 

75# - name: user1 

76# id: person.user1 

77# 

78# Lovelace configuration 

79# Create a new card with a vertical_layout and add the shops' input_select and shopping list card 

80# as in the yaml example below: 

81# 

82# title: Shopping list 

83# views: 

84# - cards: 

85# - type: vertical-stack 

86# cards: 

87# - type: entities 

88# entities: 

89# - entity: input_select.shops 

90# - type: shopping-list 

91# 

92 

93 

94class ShoppingList(hass.Hass): 

95 def initialize(self): 

96 """ 

97 Initialize the shopping list manager application. 

98 

99 This method sets up the necessary listeners and event handlers for the shopping list manager. 

100 It initializes the active shop change callback, shopping list update callback, and zone change 

101 callbacks for each specified person. 

102 

103 Returns: 

104 None 

105 """ 

106 self.log("Starting multiple shopping list manager") 

107 

108 self.listen_state(self.callback_active_shop_changed, self.args["shops"]) 

109 self.listen_event(self.callback_shopping_list_changed, "shopping_list_updated") 

110 

111 if "persons" in self.args: 

112 for person in self.args["persons"]: 

113 self.listen_state( 

114 self.callback_zone_changed, 

115 person["id"], 

116 name=person["name"], 

117 ) 

118 

119 # Note: cancel_listen_event has no effect when executed within callback_active_shop_changed 

120 # A workaround to avoid burst call to callback_shopping_list_changed is to manage 

121 # a flag raised during update which deactivate the callback and clear this flag with a timer 

122 self.updating = False 

123 

124 def update_completed(self, cb_args): 

125 """ 

126 Update completed callback for the shopping list manager. 

127 

128 This method is called when the shopping list update process is completed. It sets the 'updating' 

129 flag to False, indicating that the update process is finished. 

130 

131 Args: 

132 cb_args: Callback arguments (not used in this method). 

133 

134 Returns: 

135 None 

136 """ 

137 self.updating = False 

138 self.log("Shopping list updated") 

139 

140 def activate_shop(self, shop): 

141 """ 

142 Activate a new shop and initialize its shopping list. 

143 

144 This method is responsible for changing the active shop and initializing its shopping list. 

145 It first checks if the shopping list is currently being updated and returns early if so. 

146 Then, it performs shopping list update using call_service to homeassistant' shoppinglist plugin. 

147 

148 Args: 

149 shop (str): The shop identifier to activate. 

150 

151 Returns: 

152 bool: True if the shop's shopping list contains incomplete items, False otherwise. 

153 """ 

154 if self.updating is True: 

155 # A shop change has just occurs lets ignore this call 

156 return False 

157 

158 self.log(f"Active shop has changed to {shop}") 

159 

160 # Stop listen on shopping list change since all call_service bellow will add a callback_shopping_list_changed 

161 # call in a FIFO which will be processed once current callback will be completed 

162 self.updating = True 

163 

164 # Clear current shopping list 

165 self.call_service("shopping_list/complete_all") 

166 self.call_service("shopping_list/clear_completed_items") 

167 

168 has_incomplete = False 

169 

170 # Open shop's sopping list backup 

171 with open(f"/config/.shopping_list_{shop}.json", "r") as file: 

172 data = json.load(file) 

173 for item in data: 

174 # Add items from backup 

175 self.call_service("shopping_list/add_item", name=item["name"]) 

176 # Note: Complete is set in a second loop and after a tempo because I notice that sometime the list was not 

177 # recreated correctly maybe because of too fast service calls 

178 # /!\ sleep should be avoided in appdaemon application but it mandatory to make update works 

179 # I prefer not using 'run_in' to have have to open the file a second time 

180 time.sleep(self.args["tempo"]) 

181 for item in data: 

182 if item["complete"]: 

183 # Set completion from backup 

184 self.call_service("shopping_list/complete_item", name=item["name"]) 

185 else: 

186 has_incomplete = True 

187 

188 # Reactivate listen on shopping list change in one second (when callback burst will be finished) 

189 self.run_in(self.update_completed, 1) 

190 return has_incomplete 

191 

192 def callback_active_shop_changed(self, entity, attribute, old, new, kwargs): 

193 """ 

194 Callback for handling changes in the active shop. 

195 

196 This method is called when the active shop changes and triggers the activation of the new shop. 

197 

198 Args: 

199 Arguments as define into Appdaemon callback documentation. 

200 

201 Returns: 

202 None 

203 """ 

204 self.activate_shop(new) 

205 

206 def callback_shopping_list_changed(self, event_name, data, kwargs): 

207 """ 

208 Callback for handling changes in the shopping list. 

209 

210 This method is called when an itel of the shoppinglist is updated and triggers the creation or update of 

211 the backup shopping list for the active shop. 

212 

213 Args: 

214 Arguments as define into Appdaemon event documentation. 

215 

216 Returns: 

217 None 

218 """ 

219 if self.updating is True: 

220 # A shop change has just occurs lets ignore this call 

221 return 

222 # Copy active shopping list to shop's backup 

223 shop = self.get_state(self.args["shops"]) 

224 shutil.copyfile("/config/.shopping_list.json", f"/config/.shopping_list_{shop}.json") 

225 

226 def callback_zone_changed(self, entity, attribute, old, new, kwargs): 

227 """ 

228 Callback for handling changes in the zone of a person. 

229 

230 This method is called when the zone of a person changes. It handles actions based on entering 

231 or leaving a zone, such as loading the appropriate shopping list and sending or clearing notifications. 

232 

233 Args: 

234 Arguments as define into Appdaemon callback documentation. 

235 

236 Returns: 

237 None 

238 """ 

239 self.log(f"Zone changed to {new} for {entity}") 

240 

241 if self.get_state(f"zone.{new.lower()}") is not None: 

242 # Entering in a shop 

243 shop_zone = self.get_state(f"zone.{new.lower()}", attribute="friendly_name") 

244 for shop in self.get_state(self.args["shops"], attribute="options"): 

245 if shop_zone.startswith(shop): 

246 self.log(f"{shop} > loading shopping list") 

247 has_incomplete = self.activate_shop(shop) 

248 self.log(f"{shop} > shopping list loaded.") 

249 self.select_option(self.args["shops"], shop) 

250 self.log(f"{shop} > input_select updated.") 

251 # Send notification only if incomplete item are present in the list 

252 if has_incomplete: 

253 self.log("Send notification") 

254 self.fire_event( 

255 "NOTIFIER", 

256 action=f"send_to_{kwargs['name']}", 

257 title=f"{shop}: {self.args['notification_title']}", 

258 message=self.args["notification_message"], 

259 icon="mdi-cart", 

260 color="deep-orange", 

261 tag="shoppinglist", 

262 click_url=self.args["notification_url"], 

263 until=[ 

264 {"entity_id": entity, "old_state": shop_zone}, 

265 ], 

266 )