3636
3737logger = logging .getLogger ()
3838
39+ DEFAULT_TIMEOUT = 0.01
3940
40- class HomeAssistant :
41- """Handles HTTP interactions with Home Assistant API.
4241
43- This class manages a requests session configured for Home Assistant,
44- including authentication, SSL settings, and API calls.
42+ class ConfigurationLoader :
43+ """Loads configuration from a file."""
44+
45+ @staticmethod
46+ def load (filename : str ) -> dict :
47+ """Load configuration from a file."""
48+ try :
49+ with open (filename , encoding = "utf-8" ) as f :
50+ return json .load (f )
51+ except json .JSONDecodeError as e :
52+ raise ValueError (f"Invalid JSON in config file { filename } : { e } " )
53+
54+
55+ class Configuration :
56+ """Loads and parses configuration from JSON file or dict.
57+
58+ Handles default values and type conversions for Home Assistant settings.
4559 """
4660
47- def __init__ (self , config : "Configuration" ) -> None :
48- """Initialize the HomeAssistant client.
61+ def __init__ (self , json : dict ) -> None :
62+ """Initialize configuration from file or dict."""
63+ self ._json = json
64+ self .url = self .get_url (self .get (["url" , "ha_url" ]))
65+ self .ssl_verify = self .get (["ssl_verify" , "ha_cert" ], True )
66+ self .bearer_token = self .get (["bearer_token" ], "" )
67+ self .ssl_client = self .get (["ssl_client" ], "" )
68+ # Convert list to tuple for SSL client cert if provided
69+ if isinstance (self .ssl_client , list ):
70+ self .ssl_client = tuple (self .ssl_client )
71+ self .debug = self .get (["debug" ], False )
72+
73+ def get (self , keys : List [str ], default : Any = None ) -> Any :
74+ """Retrieve value from config dict using multiple possible keys.
75+
76+ Args:
77+ keys (list): List of possible key names to check.
78+ default: Default value if none of the keys are found.
79+
80+ Returns:
81+ The value associated with the first matching key, or default.
82+ """
83+ return next ((self ._json [key ] for key in keys if key in self ._json ), default )
84+
85+ def get_url (self , url : str ) -> str :
86+ """Normalize Home Assistant base URL.
87+
88+ Removes '/api' suffix and trailing slashes.
89+
90+ Args:
91+ url (str): Raw URL from config.
92+
93+ Returns:
94+ str: Normalized base URL.
95+
96+ Raises:
97+ ValueError: If URL is missing.
98+ """
99+ if not url :
100+ raise ValueError ('Property "url" is missing in config' )
101+ return url .replace ("/api" , "" ).rstrip ("/" )
102+
103+
104+ class SessionFactory :
105+ """Factory for creating configured requests sessions."""
106+
107+ @staticmethod
108+ def create_session (config : Configuration ) -> requests .Session :
109+ """Create a configured requests session for Home Assistant API.
49110
50111 Args:
51- config (Configuration): Configuration object containing API settings.
112+ config (Configuration): Configuration object with API settings.
113+
114+ Returns:
115+ requests.Session: Configured session ready for API calls.
52116 """
53- self . config = config
54- self . session = requests . Session ()
55- # Set up session headers for authentication and content type
56- self . session .headers .update (
117+ session = requests . Session ()
118+
119+ # Set up authentication and headers
120+ session .headers .update (
57121 {
58122 "Authorization" : f"Bearer { config .bearer_token } " ,
59123 "content-type" : "application/json" ,
60- "User-Agent" : self . get_user_agent (),
124+ "User-Agent" : SessionFactory . _get_user_agent (),
61125 }
62126 )
63- self .session .verify = config .ssl_verify
64- self .session .cert = config .ssl_client
127+
128+ # Configure SSL settings
129+ session .verify = config .ssl_verify
130+ session .cert = config .ssl_client
131+
132+ return session
133+
134+ @staticmethod
135+ def _get_user_agent () -> str :
136+ """Generate a user agent string for requests.
137+
138+ Returns:
139+ str: User agent string including AWS region and default requests UA.
140+ """
141+ aws_region = os .environ .get ("AWS_DEFAULT_REGION" , "unknown" )
142+ return f"Home Assistant Alexa Smart Home Skill - { aws_region } - { requests .utils .default_user_agent ()} "
143+
144+
145+ class HomeAssistant :
146+ """Handles HTTP interactions with Home Assistant API."""
147+
148+ def __init__ (self , base_url : str , session : requests .Session ) -> None :
149+ """Initialize the HomeAssistant client.
150+
151+ Args:
152+ base_url (str): Base URL for HA instance.
153+ session (requests.Session, optional): Pre-configured session. If None, creates one.
154+ """
155+ self .base_url = base_url
156+ self .session = session
65157
66158 def build_url (self , endpoint : str ) -> str :
67159 """Build the full API URL for a given endpoint.
@@ -72,15 +164,7 @@ def build_url(self, endpoint: str) -> str:
72164 Returns:
73165 str: The complete URL including base URL and '/api/'.
74166 """
75- return f"{ self .config .url } /api/{ endpoint } "
76-
77- def get_user_agent (self ) -> str :
78- """Generate a user agent string for requests.
79-
80- Returns:
81- str: User agent string including AWS region and default requests UA.
82- """
83- return f"Home Assistant Alexa Smart Home Skill - { os .environ .get ('AWS_DEFAULT_REGION' )} - { requests .utils .default_user_agent ()} "
167+ return f"{ self .base_url } /api/{ endpoint } "
84168
85169 def get (self , endpoint : str ) -> Dict [str , Any ]:
86170 """Perform a GET request to the Home Assistant API.
@@ -98,9 +182,7 @@ def get(self, endpoint: str) -> Dict[str, Any]:
98182 r .raise_for_status ()
99183 return r .json ()
100184
101- def post (
102- self , endpoint : str , event : Dict [str , Any ], wait : bool = False
103- ) -> Optional [Dict [str , Any ]]:
185+ def post (self , endpoint : str , event : Dict [str , Any ], wait : bool = False ) -> Optional [Dict [str , Any ]]:
104186 """Perform a POST request to the Home Assistant API.
105187
106188 Args:
@@ -114,81 +196,19 @@ def post(
114196 Raises:
115197 requests.HTTPError: If the request fails (when waiting).
116198 """
199+ url = self .build_url (endpoint )
200+ timeout = None if wait else DEFAULT_TIMEOUT
201+
117202 try :
118- logger .debug ("calling %s with %s" , endpoint , event )
119- r = self .session .post (
120- self .build_url (endpoint ), json = event , timeout = None if wait else 0.01
121- )
122- r .raise_for_status ()
123- return r .json ()
203+ logger .debug ("calling %s with %s" , url , event )
204+ request = self .session .post (url , json = event , timeout = timeout )
205+ request .raise_for_status ()
206+ return request .json ()
124207 except requests .exceptions .ReadTimeout :
125208 logger .debug ("request for %s sent without waiting for response" , endpoint )
126209 return None
127210
128211
129- class Configuration :
130- """Loads and parses configuration from JSON file or dict.
131-
132- Handles default values and type conversions for Home Assistant settings.
133- """
134-
135- def __init__ (
136- self , filename : Optional [str ] = None , opts_dict : Optional [Dict [str , Any ]] = None
137- ) -> None :
138- """Initialize configuration from file or dict.
139-
140- Args:
141- filename (str, optional): Path to JSON config file.
142- opts_dict (dict, optional): Dict with config options.
143- """
144- self ._json = opts_dict or {}
145- if filename :
146- try :
147- with open (filename , encoding = "utf-8" ) as f :
148- self ._json = json .load (f )
149- except json .JSONDecodeError as e :
150- raise ValueError (f"Invalid JSON in config file { filename } : { e } " ) from e
151-
152- self .url = self .get_url (self .get (["url" , "ha_url" ]))
153- self .ssl_verify = self .get (["ssl_verify" , "ha_cert" ], True )
154- self .bearer_token = self .get (["bearer_token" ], "" )
155- self .ssl_client = self .get (["ssl_client" ], "" )
156- # Convert list to tuple for SSL client cert if provided
157- if isinstance (self .ssl_client , list ):
158- self .ssl_client = tuple (self .ssl_client )
159- self .debug = self .get (["debug" ], False )
160-
161- def get (self , keys : List [str ], default : Any = None ) -> Any :
162- """Retrieve value from config dict using multiple possible keys.
163-
164- Args:
165- keys (list): List of possible key names to check.
166- default: Default value if none of the keys are found.
167-
168- Returns:
169- The value associated with the first matching key, or default.
170- """
171- return next ((self ._json [key ] for key in keys if key in self ._json ), default )
172-
173- def get_url (self , url : str ) -> str :
174- """Normalize Home Assistant base URL.
175-
176- Removes '/api' suffix and trailing slashes.
177-
178- Args:
179- url (str): Raw URL from config.
180-
181- Returns:
182- str: Normalized base URL.
183-
184- Raises:
185- ValueError: If URL is missing.
186- """
187- if not url :
188- raise ValueError ('Property "url" is missing in config' )
189- return url .replace ("/api" , "" ).rstrip ("/" )
190-
191-
192212def event_handler (event : Dict [str , Any ], _context : Any ) -> Optional [Dict [str , Any ]]:
193213 """AWS Lambda event handler for Alexa smart home events.
194214
@@ -201,8 +221,8 @@ def event_handler(event: Dict[str, Any], _context: Any) -> Optional[Dict[str, An
201221 Returns:
202222 dict or None: Response from Home Assistant API, or None if timed out.
203223 """
204- config = Configuration ("config.json" )
224+ config = Configuration (ConfigurationLoader . load ( "config.json" ) )
205225 if config .debug :
206226 logger .setLevel (logging .DEBUG )
207- ha = HomeAssistant (config )
227+ ha = HomeAssistant (config . url , SessionFactory . create_session ( config ) )
208228 return ha .post ("alexa/smart_home" , event , wait = True )
0 commit comments