From fbbeda6c8618d2cf00692f39bc998e9c63a6dd40 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 13 Jul 2026 15:55:16 +0200 Subject: [PATCH 01/13] Fix NextScale asynchronous web client The NextScale SMM path still used the removed http.client-style interface against the asynchronous WebConnection implementation. Login, configuration, diagnostic, and firmware operations consequently called unavailable methods or left request coroutines unresolved. Make web-client creation asynchronous, migrate the affected requests to grab_response_with_status(), and await the cached client accessor. Correct the cache expiry comparison so fresh authenticated clients are reused and stale clients are renewed. --- .../aiohmi/ipmi/oem/lenovo/nextscale.py | 90 +++++++++---------- 1 file changed, 40 insertions(+), 50 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index 9fa64fc7..0c89e825 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -409,15 +409,15 @@ class SMMClient(object): async def get_bmc_configuration(self, variant): settings = {} - wc = self.wc - wc.request( - 'POST', '/data', + wc = await self.wc() + rspbody, status, _ = await wc.grab_response_with_status( + '/data', ('get=passwordMinLength,passwordForceChange,passwordDurationDays,' 'passwordExpireWarningDays,passwordChangeInterval,' 'passwordReuseCheckNum,passwordFailAllowdNum,' 'passwordLockoutTimePeriod,timeZone')) - rsp = wc.getresponse() - rspbody = rsp.read() + if status != 200: + raise Exception(rspbody) accountinfo = fromstring(rspbody) for rule in self.rulemap: ruleinfo = accountinfo.find(self.rulemap[rule]) @@ -693,9 +693,10 @@ class SMMClient(object): changeset[key]['value'])) if rules: rules = 'set={0}'.format(','.join(rules)) - wc = self.wc - wc.request('POST', '/data', rules) - wc.getresponse().read() + wc = await self.wc() + rsp, status, _ = await wc.grab_response_with_status('/data', rules) + if status != 200: + raise Exception(rsp) if powercfg != [None, None]: if variant != 6: if None in powercfg: @@ -734,12 +735,12 @@ class SMMClient(object): username = bytes(rsp['data']).rstrip(b'\x00') if not isinstance(username, str): username = username.decode('utf8') - wc = self.wc - wc.request( - 'POST', '/data', 'set=user({0},1,{1},511,,4,15,0)'.format( + wc = await self.wc() + rsp, status, _ = await wc.grab_response_with_status( + '/data', 'set=user({0},1,{1},511,,4,15,0)'.format( uid, username)) - rsp = wc.getresponse() - rsp.read() + if status != 200: + raise Exception(rsp) async def reseat_bay(self, bay): bay = int(bay) @@ -791,7 +792,7 @@ class SMMClient(object): rsp = await self.ipmicmd.raw_command(netfn=0x34, command=0x12, data=[1]) if progress: progress({'phase': 'initializing', 'progress': initpct}) - wc = self.wc + wc = await self.wc() if wc is None: raise Exception("Failed to connect to web api") if variant and variant >> 5: @@ -827,23 +828,19 @@ class SMMClient(object): fru['Model'] = mnum.strip(b' \x00\xff').replace(b'\xff', b'') return fru - def get_webclient(self): + async def get_webclient(self): cv = self.ipmicmd.certverify - wc = webclient.SecureHTTPConnection(self.smm, 443, verifycallback=cv) wc = webclient.WebConnection(self.smm, 443, verifycallback=cv) wc.vintage = util._monotonic_time() - wc.connect() loginform = urlencode( { 'user': self.username, 'password': self.password } ) - wc.request('POST', '/data/login', loginform) - rsp = wc.getresponse() - if rsp.status != 200: - raise Exception(rsp.read()) - authdata = rsp.read() + authdata, status, _ = await wc.grab_response_with_status('/data/login', loginform) + if status != 200: + raise Exception(authdata) authdata = fromstring(authdata) for data in authdata.findall('authResult'): if int(data.text) != 0: @@ -860,11 +857,9 @@ class SMMClient(object): wc.st2 = data.text if not wc.st2: # This firmware puts tokens in the html file, parse that - wc.request('GET', '/index.html') - rsp = wc.getresponse() - if rsp.status != 200: - raise Exception(rsp.read()) - indexhtml = rsp.read() + indexhtml, status, _ = await wc.grab_response_with_status('/index.html', method='GET') + if status != 200: + raise Exception(indexhtml) if not isinstance(indexhtml, str): indexhtml = indexhtml.decode('utf8') for line in indexhtml.split('\n'): @@ -875,10 +870,8 @@ class SMMClient(object): wc.st2 = line.split()[-1].replace( '"', '').replace(',', '') if not wc.st2: - wc.request('GET', '/scripts/index.ajs') - rsp = wc.getresponse() - body = rsp.read() - if rsp.status != 200: + body, status, _ = await wc.grab_response_with_status('/scripts/index.ajs', method='GET') + if status != 200: raise Exception(body) if not isinstance(body, str): body = body.decode('utf8') @@ -1007,10 +1000,10 @@ class SMMClient(object): data = z.open(filename) break progress({'phase': 'upload', 'progress': 0.0}) - wc = self.wc - wc.request('POST', '/data', 'set=fwType:10') # SMM firmware - rsp = wc.getresponse() - rsp.read() + wc = await self.wc() + rsp, status, _ = await wc.grab_response_with_status('/data', 'set=fwType:10') # SMM firmware + if status != 200: + raise Exception(rsp) url = '/fwupload/fwupload.esp?ST1={0}'.format(wc.st1) fu = await webclient.make_uploader( wc, url, filename, data, formname='fileUpload', @@ -1025,31 +1018,28 @@ class SMMClient(object): 'progress': 100 * await fu.get_progress()}) progress({'phase': 'validating', 'progress': 0.0}) url = '/data' - wc.request('POST', url, 'get=fwVersion,spfwInfo') - rsp = wc.getresponse() - rsp.read() - if rsp.status != 200: + rsp, status, _ = await wc.grab_response_with_status(url, 'get=fwVersion,spfwInfo') + if status != 200: raise Exception('Error validating firmware') progress({'phase': 'apply', 'progress': 0.0}) - wc.request('POST', '/data', 'set=securityrollback:1') - wc.getresponse().read() - wc.request('POST', '/data', 'set=fwUpdate:1') - rsp = wc.getresponse() - rsp.read() + rsp, status, _ = await wc.grab_response_with_status('/data', 'set=securityrollback:1') + if status != 200: + raise Exception(rsp) + rsp, status, _ = await wc.grab_response_with_status('/data', 'set=fwUpdate:1') + if status != 200: + raise Exception(rsp) complete = False tries = 0 while not complete: await ipmisession.Session.pause(3) - wc.request('POST', '/data', 'get=fwProgress,fwUpdate') try: - rsp = wc.getresponse() - progdata = rsp.read() + progdata, status, _ = await wc.grab_response_with_status('/data', 'get=fwProgress,fwUpdate') except Exception: if tries > 2: break tries += 1 continue - if rsp.status != 200: + if status != 200: raise Exception('Error applying firmware') progdata = fromstring(progdata) if progdata.findall('fwUpdate')[0].text == 'invalid signature': @@ -1113,7 +1103,7 @@ class SMMClient(object): self._wc = None async def wc(self): - if (not self._wc or self._wc.broken - or self._wc.vintage < util._monotonic_time() + 30): + if (not self._wc or (self._wc.vintage + and self._wc.vintage < util._monotonic_time() - 30)): self._wc = await self.get_webclient() return self._wc From 8817ee6deb50d5a8c6a11f89a2a7c896b281eeec Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 13 Jul 2026 16:05:53 +0200 Subject: [PATCH 02/13] Await NextScale SMM settings operations The SMM hostname, domain, and NTP helpers looked synchronous even though their web transport is asynchronous. Removing awaits in the Lenovo OEM handler therefore returned unresolved coroutine work instead of completed settings results. Convert the SMM settings and logout helpers to the asynchronous web interface, validate HTTP status responses, and await each operation from the OEM handler so callers only observe completed results. --- .../aiohmi/ipmi/oem/lenovo/handler.py | 14 +- .../aiohmi/ipmi/oem/lenovo/nextscale.py | 120 +++++++++--------- 2 files changed, 65 insertions(+), 69 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/handler.py b/confluent_server/aiohmi/ipmi/oem/lenovo/handler.py index d8833ed4..2999ebdf 100755 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/handler.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/handler.py @@ -360,7 +360,7 @@ class OEMHandler(generic.OEMHandler): srvs.append(ntpres['data'][129:257].rstrip('\x00')) return srvs if await self.is_fpc(): - return self.smmhandler.get_ntp_servers() + return await self.smmhandler.get_ntp_servers() if self.has_tsma: return await self.tsmahandler.get_ntp_servers() return () @@ -375,7 +375,7 @@ class OEMHandler(generic.OEMHandler): netfn=0x32, command=0xa8, data=(3, 0), timeout=15) return True if await self.is_fpc(): - self.smmhandler.set_ntp_enabled(enabled) + await self.smmhandler.set_ntp_enabled(enabled) return True if self.has_tsma: await self.tsmahandler.set_ntp_enabled(enabled) @@ -393,7 +393,7 @@ class OEMHandler(generic.OEMHandler): if not 0 <= index <= 2: raise pygexc.InvalidParameterValue( 'SMM supports indexes 0 through 2') - self.smmhandler.set_ntp_server(server, index) + await self.smmhandler.set_ntp_server(server, index) return True elif self.has_tsma: if not (0 <= index <= 1): @@ -940,7 +940,7 @@ class OEMHandler(generic.OEMHandler): name += rsp['data'][:] return name.rstrip('\x00') elif await self.is_fpc(): - return self.smmhandler.get_domain() + return await self.smmhandler.get_domain() async def set_oem_domain_name(self, name): if await self.has_tsm(): @@ -959,20 +959,20 @@ class OEMHandler(generic.OEMHandler): await self._restart_dns() return elif await self.is_fpc(): - self.smmhandler.set_domain(name) + await self.smmhandler.set_domain(name) async def set_hostname(self, hostname): if await self.has_xcc(): return await self.immhandler.set_hostname(hostname) elif await self.is_fpc(): - return self.smmhandler.set_hostname(hostname) + return await self.smmhandler.set_hostname(hostname) return await super(OEMHandler, self).set_hostname(hostname) async def get_hostname(self): if await self.has_xcc(): return await self.immhandler.get_hostname() elif await self.is_fpc(): - return self.smmhandler.get_hostname() + return await self.smmhandler.get_hostname() return await super(OEMHandler, self).get_hostname() """ Gets a remote console launcher for a Lenovo ThinkServer. diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index 0c89e825..be51f335 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -887,90 +887,84 @@ class SMMClient(object): wc.set_header('ST2', wc.st2) return wc - def set_hostname(self, hostname): - wc = self.wc - wc.request('POST', '/data', 'set=hostname:' + hostname) - rsp = wc.getresponse() - if rsp.status != 200: - raise Exception(rsp.read()) - rsp.read() - self.logout() + async def set_hostname(self, hostname): + wc = await self.wc() + rsp, status, _ = await wc.grab_response_with_status('/data', 'set=hostname:' + hostname) + if status != 200: + raise Exception(rsp) + await self.logout() - def get_hostname(self): - currinfo = self.get_netinfo() - self.logout() + async def get_hostname(self): + currinfo = await self.get_netinfo() + await self.logout() for data in currinfo.find('netConfig').findall('hostname'): return data.text - def get_netinfo(self): - wc = self.wc - wc.request('POST', '/data', 'get=hostname') - rsp = wc.getresponse() - data = rsp.read() - if rsp.status == 400: - wc.request('POST', '/data?get=hostname', '') - rsp = wc.getresponse() - data = rsp.read() - if rsp.status != 200: + async def get_netinfo(self): + wc = await self.wc() + data, status, _ = await wc.grab_response_with_status('/data', 'get=hostname') + if status == 400: + data, status, _ = await wc.grab_response_with_status('/data?get=hostname', '') + if status != 200: raise Exception(data) currinfo = fromstring(data) return currinfo - def set_domain(self, domain): - wc = self.wc - wc.request('POST', '/data', 'set=dnsDomain:' + domain) - rsp = wc.getresponse() - if rsp.status != 200: - raise Exception(rsp.read()) - rsp.read() - self.logout() + async def set_domain(self, domain): + wc = await self.wc() + rsp, status, _ = await wc.grab_response_with_status('/data', 'set=dnsDomain:' + domain) + if status != 200: + raise Exception(rsp) + await self.logout() - def get_domain(self): - currinfo = self.get_netinfo() - self.logout() + async def get_domain(self): + currinfo = await self.get_netinfo() + await self.logout() for data in currinfo.find('netConfig').findall('dnsDomain'): return data.text - def get_ntp_enabled(self, variant): - wc = self.wc - wc.request('POST', '/data', 'get=ntpOpMode') - rsp = wc.getresponse() - info = fromstring(rsp.read()) - self.logout() + async def get_ntp_enabled(self, variant): + wc = await self.wc() + rsp, status, _ = await wc.grab_response_with_status('/data', 'get=ntpOpMode') + if status != 200: + raise Exception(rsp) + info = fromstring(rsp) + await self.logout() for data in info.findall('ntpOpMode'): return data.text == '1' - def set_ntp_enabled(self, enabled): - wc = self.wc - wc.request('POST', '/data', 'set=ntpOpMode:{0}'.format( + async def set_ntp_enabled(self, enabled): + wc = await self.wc() + result, status, _ = await wc.grab_response_with_status('/data', 'set=ntpOpMode:{0}'.format( 1 if enabled else 0)) - rsp = wc.getresponse() - result = rsp.read() + if status != 200: + raise Exception(result) if not isinstance(result, str): result = result.decode('utf8') - self.logout() + await self.logout() if 'ok' not in result: raise Exception("Unrecognized result: " + result) - def set_ntp_server(self, server, index): - wc = self.wc - wc.request('POST', '/data', 'set=ntpServer{0}:{1}'.format( + async def set_ntp_server(self, server, index): + wc = await self.wc() + result, status, _ = await wc.grab_response_with_status('/data', 'set=ntpServer{0}:{1}'.format( index + 1, server)) - rsp = wc.getresponse() - result = rsp.read() + if status != 200: + raise Exception(result) if not isinstance(result, str): result = result.decode('utf8') if 'ok' not in result: raise Exception("Unrecognized result: " + result) - self.logout() + await self.logout() return True - def get_ntp_servers(self): - wc = self.wc - wc.request( - 'POST', '/data', 'get=ntpServer1,ntpServer2,ntpServer3') - rsp = wc.getresponse() - result = fromstring(rsp.read()) + async def get_ntp_servers(self): + wc = await self.wc() + rsp, status, _ = await wc.grab_response_with_status( + '/data', 'get=ntpServer1,ntpServer2,ntpServer3') + if status != 200: + raise Exception(rsp) + result = fromstring(rsp) srvs = [] for data in result.findall('ntpServer1'): srvs.append(data.text) @@ -978,7 +972,7 @@ class SMMClient(object): srvs.append(data.text) for data in result.findall('ntpServer3'): srvs.append(data.text) - self.logout() + await self.logout() return srvs async def update_firmware(self, filename, data=None, progress=None, bank=None): @@ -1095,12 +1089,14 @@ class SMMClient(object): b' \x00\xff').decode('utf8')) return psui - def logout(self): - wc = self.wc - wc.request('POST', '/data/logout', None) - rsp = wc.getresponse() - rsp.read() + async def logout(self): + wc = self._wc self._wc = None + if wc is None: + return + rsp, status, _ = await wc.grab_response_with_status('/data/logout', None, method='POST') + if status != 200: + raise Exception(rsp) async def wc(self): if (not self._wc or (self._wc.vintage From ca81907d2535100154ea79725fca9ae8bf15a147 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Sun, 26 Jul 2026 23:57:53 +0200 Subject: [PATCH 03/13] Restore SMM web request semantics lost in the async port The old WebConnection.request() added a 'Content-Type: application/x-www-form-urlencoded' header to any POST carrying a body, but grab_response_with_status() only sets a content type for dict payloads, so the SMM login and every /data form POST now go out as text/plain. This is not a fix for an observed failure: an SMM running FPC variant 38 was measured accepting a text/plain login exactly as readily as a urlencoded one. It restores the header the synchronous code always sent and that the TSM and IMM handlers still set explicitly, rather than relying on every SMM firmware level being equally lax about what it will parse. Also stop hard failing on responses the synchronous code discarded on purpose. 'set=securityrollback:1' is only understood by newer SMM2 firmware. And /data/logout answers 401 once the session is gone, as measured on that same SMM, so raising on a non-200 there turns a completed hostname, domain or NTP operation into a spurious error. --- .../aiohmi/ipmi/oem/lenovo/nextscale.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index be51f335..f83d56b2 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -736,11 +736,9 @@ class SMMClient(object): if not isinstance(username, str): username = username.decode('utf8') wc = await self.wc() - rsp, status, _ = await wc.grab_response_with_status( + await wc.grab_response_with_status( '/data', 'set=user({0},1,{1},511,,4,15,0)'.format( uid, username)) - if status != 200: - raise Exception(rsp) async def reseat_bay(self, bay): bay = int(bay) @@ -831,6 +829,7 @@ class SMMClient(object): async def get_webclient(self): cv = self.ipmicmd.certverify wc = webclient.WebConnection(self.smm, 443, verifycallback=cv) + wc.set_header('Content-Type', 'application/x-www-form-urlencoded') wc.vintage = util._monotonic_time() loginform = urlencode( { @@ -1016,9 +1015,8 @@ class SMMClient(object): if status != 200: raise Exception('Error validating firmware') progress({'phase': 'apply', 'progress': 0.0}) - rsp, status, _ = await wc.grab_response_with_status('/data', 'set=securityrollback:1') - if status != 200: - raise Exception(rsp) + # only understood by newer SMM2 firmware, ignore rejection by older + await wc.grab_response_with_status('/data', 'set=securityrollback:1') rsp, status, _ = await wc.grab_response_with_status('/data', 'set=fwUpdate:1') if status != 200: raise Exception(rsp) @@ -1094,9 +1092,8 @@ class SMMClient(object): self._wc = None if wc is None: return - rsp, status, _ = await wc.grab_response_with_status('/data/logout', None, method='POST') - if status != 200: - raise Exception(rsp) + # best effort, a stale session must not fail the caller's operation + await wc.grab_response_with_status('/data/logout', None, method='POST') async def wc(self): if (not self._wc or (self._wc.vintage From 5135a6cd3d1f807e511e6517cf7bdeeb1d4395d1 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 00:28:10 +0200 Subject: [PATCH 04/13] Make the SMM web session cache safe to share Now that the expiry comparison actually caches a client, the session it holds is shared, so tearing it down and replacing it needs the same care the IMM handler already takes: Dispose of an expired session with a logout instead of dropping the reference, otherwise every refresh leaves an authenticated session behind on an SMM that only has a handful of slots. That logout has to tolerate a session the SMM has already reaped, hence the except. Guard the login itself, so two coroutines arriving at an empty or expired cache do not both log in and orphan one of the two sessions. Stamp the vintage once the login round trips are done rather than before, so a slow SMM cannot hand back a client that is already expired. --- .../aiohmi/ipmi/oem/lenovo/nextscale.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index f83d56b2..bbadc663 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -384,6 +384,7 @@ class SMMClient(object): self.username = ipmicmd.ipmi_session.userid self.password = ipmicmd.ipmi_session.password self._wc = None + self.weblogging = False async def clear_bmc_configuration(self): await self.ipmicmd.raw_command(0x32, 0xad) @@ -830,7 +831,6 @@ class SMMClient(object): cv = self.ipmicmd.certverify wc = webclient.WebConnection(self.smm, 443, verifycallback=cv) wc.set_header('Content-Type', 'application/x-www-form-urlencoded') - wc.vintage = util._monotonic_time() loginform = urlencode( { 'user': self.username, @@ -884,6 +884,7 @@ class SMMClient(object): if not wc.st2: raise Exception('Unable to locate ST2 token') wc.set_header('ST2', wc.st2) + wc.vintage = util._monotonic_time() return wc async def set_hostname(self, hostname): @@ -1093,10 +1094,21 @@ class SMMClient(object): if wc is None: return # best effort, a stale session must not fail the caller's operation - await wc.grab_response_with_status('/data/logout', None, method='POST') + try: + await wc.grab_response_with_status('/data/logout', None, method='POST') + except Exception: + pass async def wc(self): - if (not self._wc or (self._wc.vintage - and self._wc.vintage < util._monotonic_time() - 30)): - self._wc = await self.get_webclient() + while self.weblogging: + await ipmisession.Session.pause(0.25) + self.weblogging = True + try: + if (not self._wc or (self._wc.vintage + and self._wc.vintage < util._monotonic_time() - 30)): + # in case the existing session is still valid, dispose of it + await self.logout() + self._wc = await self.get_webclient() + finally: + self.weblogging = False return self._wc From 6e6cbce0a2741dc57beeef55f7c8c6b3df522c89 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 00:28:10 +0200 Subject: [PATCH 05/13] Do not report an interrupted firmware update as complete The retry counter is there to ride out a few unanswered polls, but exhausting it broke out of the loop with complete still unset and fell through to the 'complete' return, so an SMM that stopped answering part way through an apply was reported to the operator as updated. Raise instead; a genuine finish still leaves the loop on the progress reaching 100. --- confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index bbadc663..f15245c2 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -1029,7 +1029,7 @@ class SMMClient(object): progdata, status, _ = await wc.grab_response_with_status('/data', 'get=fwProgress,fwUpdate') except Exception: if tries > 2: - break + raise tries += 1 continue if status != 200: From 474e2bd9757a33a5e15d3df3f3b625324afcfed0 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 00:28:10 +0200 Subject: [PATCH 06/13] Drop unreachable web client check in get_diagnostic_data wc() either returns a client or propagates the exception raised while logging in; it cannot return None the way connect() could. --- confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index f15245c2..31bfe9f8 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -792,8 +792,6 @@ class SMMClient(object): if progress: progress({'phase': 'initializing', 'progress': initpct}) wc = await self.wc() - if wc is None: - raise Exception("Failed to connect to web api") if variant and variant >> 5: url = '/preview/smm2-ffdc.tgz?ST1={0}'.format(wc.st1) else: From 1031bad407962eee3e9838d22a4b0719f8331e86 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 00:28:59 +0200 Subject: [PATCH 07/13] Keep the SMM web session across settings operations Every getter and setter logged out on the way out, which nulled the cached client and made the session cache inert on exactly the paths it was meant to serve: a single nodeconfig walk of ntp costs two full logins for the read and one per server for the write, each of them a fresh TLS handshake plus, on firmware that omits st2, two extra page fetches to scrape the tokens. Leave the session in place and let wc() dispose of it once it expires. This also stops one coroutine's logout from invalidating the session another coroutine just fetched and is about to post with. --- confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index 31bfe9f8..e8824a2d 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -890,11 +890,9 @@ class SMMClient(object): rsp, status, _ = await wc.grab_response_with_status('/data', 'set=hostname:' + hostname) if status != 200: raise Exception(rsp) - await self.logout() async def get_hostname(self): currinfo = await self.get_netinfo() - await self.logout() for data in currinfo.find('netConfig').findall('hostname'): return data.text @@ -913,11 +911,9 @@ class SMMClient(object): rsp, status, _ = await wc.grab_response_with_status('/data', 'set=dnsDomain:' + domain) if status != 200: raise Exception(rsp) - await self.logout() async def get_domain(self): currinfo = await self.get_netinfo() - await self.logout() for data in currinfo.find('netConfig').findall('dnsDomain'): return data.text @@ -927,7 +923,6 @@ class SMMClient(object): if status != 200: raise Exception(rsp) info = fromstring(rsp) - await self.logout() for data in info.findall('ntpOpMode'): return data.text == '1' @@ -939,7 +934,6 @@ class SMMClient(object): raise Exception(result) if not isinstance(result, str): result = result.decode('utf8') - await self.logout() if 'ok' not in result: raise Exception("Unrecognized result: " + result) @@ -953,7 +947,6 @@ class SMMClient(object): result = result.decode('utf8') if 'ok' not in result: raise Exception("Unrecognized result: " + result) - await self.logout() return True async def get_ntp_servers(self): @@ -970,7 +963,6 @@ class SMMClient(object): srvs.append(data.text) for data in result.findall('ntpServer3'): srvs.append(data.text) - await self.logout() return srvs async def update_firmware(self, filename, data=None, progress=None, bank=None): From d665064dbd9961435f1a0db7c3e31af672a7e0ff Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 14:58:58 +0200 Subject: [PATCH 08/13] Hold the SMM web session across long operations A firmware update posts on one session for the minutes its apply loop runs, and an FFDC collection downloads on the session it acquired, but wc() judges a session by its age alone, so a settings call arriving thirty seconds in logged that session out from underneath them. Flag the long operations the way the IMM and XCC handlers already do and leave their session in place. --- .../aiohmi/ipmi/oem/lenovo/nextscale.py | 130 ++++++++++-------- 1 file changed, 74 insertions(+), 56 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index e8824a2d..7fa1e451 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -385,6 +385,7 @@ class SMMClient(object): self.password = ipmicmd.ipmi_session.password self._wc = None self.weblogging = False + self.updating = False async def clear_bmc_configuration(self): await self.ipmicmd.raw_command(0x32, 0xad) @@ -798,15 +799,20 @@ class SMMClient(object): url = '/preview/smm-ffdc.tgz?ST1={0}'.format(wc.st1) if autosuffix and not savefile.endswith('.tgz'): savefile += '-smm-ffdc.tgz' - fd = webclient.make_downloader(wc, url, savefile) - while not fd.completed(): - try: - await fd.join(1) - except asyncio.TimeoutError: - pass - if progress and await fd.get_progress(): - progress({'phase': 'download', - 'progress': 100 * await fd.get_progress()}) + # the download runs on this session, keep wc() from logging it out + self.updating = True + try: + fd = webclient.make_downloader(wc, url, savefile) + while not fd.completed(): + try: + await fd.join(1) + except asyncio.TimeoutError: + pass + if progress and await fd.get_progress(): + progress({'phase': 'download', + 'progress': 100 * await fd.get_progress()}) + finally: + self.updating = False if progress: progress({'phase': 'complete'}) return savefile @@ -985,53 +991,63 @@ class SMMClient(object): break progress({'phase': 'upload', 'progress': 0.0}) wc = await self.wc() - rsp, status, _ = await wc.grab_response_with_status('/data', 'set=fwType:10') # SMM firmware - if status != 200: - raise Exception(rsp) - url = '/fwupload/fwupload.esp?ST1={0}'.format(wc.st1) - fu = await webclient.make_uploader( - wc, url, filename, data, formname='fileUpload', - otherfields={'preConfig': 'on'}) - while not fu.completed(): - try: - await fu.join(3) - except asyncio.TimeoutError: - pass - if progress: - progress({'phase': 'upload', - 'progress': 100 * await fu.get_progress()}) - progress({'phase': 'validating', 'progress': 0.0}) - url = '/data' - rsp, status, _ = await wc.grab_response_with_status(url, 'get=fwVersion,spfwInfo') - if status != 200: - raise Exception('Error validating firmware') - progress({'phase': 'apply', 'progress': 0.0}) - # only understood by newer SMM2 firmware, ignore rejection by older - await wc.grab_response_with_status('/data', 'set=securityrollback:1') - rsp, status, _ = await wc.grab_response_with_status('/data', 'set=fwUpdate:1') - if status != 200: - raise Exception(rsp) - complete = False - tries = 0 - while not complete: - await ipmisession.Session.pause(3) - try: - progdata, status, _ = await wc.grab_response_with_status('/data', 'get=fwProgress,fwUpdate') - except Exception: - if tries > 2: - raise - tries += 1 - continue + # the update runs on this session, keep wc() from logging it out + self.updating = True + try: + rsp, status, _ = await wc.grab_response_with_status( + '/data', 'set=fwType:10') # SMM firmware if status != 200: - raise Exception('Error applying firmware') - progdata = fromstring(progdata) - if progdata.findall('fwUpdate')[0].text == 'invalid signature': - raise Exception('Firmware signature invalid') - percent = float(progdata.findall('fwProgress')[0].text) + raise Exception(rsp) + url = '/fwupload/fwupload.esp?ST1={0}'.format(wc.st1) + fu = await webclient.make_uploader( + wc, url, filename, data, formname='fileUpload', + otherfields={'preConfig': 'on'}) + while not fu.completed(): + try: + await fu.join(3) + except asyncio.TimeoutError: + pass + if progress: + progress({'phase': 'upload', + 'progress': 100 * await fu.get_progress()}) + progress({'phase': 'validating', 'progress': 0.0}) + url = '/data' + rsp, status, _ = await wc.grab_response_with_status( + url, 'get=fwVersion,spfwInfo') + if status != 200: + raise Exception('Error validating firmware') + progress({'phase': 'apply', 'progress': 0.0}) + # only understood by newer SMM2 firmware, ignore rejection by older + await wc.grab_response_with_status( + '/data', 'set=securityrollback:1') + rsp, status, _ = await wc.grab_response_with_status( + '/data', 'set=fwUpdate:1') + if status != 200: + raise Exception(rsp) + complete = False + tries = 0 + while not complete: + await ipmisession.Session.pause(3) + try: + progdata, status, _ = await wc.grab_response_with_status( + '/data', 'get=fwProgress,fwUpdate') + except Exception: + if tries > 2: + raise + tries += 1 + continue + if status != 200: + raise Exception('Error applying firmware') + progdata = fromstring(progdata) + if progdata.findall('fwUpdate')[0].text == 'invalid signature': + raise Exception('Firmware signature invalid') + percent = float(progdata.findall('fwProgress')[0].text) - progress({'phase': 'apply', - 'progress': percent}) - complete = percent >= 100.0 + progress({'phase': 'apply', + 'progress': percent}) + complete = percent >= 100.0 + finally: + self.updating = False return 'complete' async def get_inventory_descriptions(self, ipmicmd, variant): @@ -1096,8 +1112,10 @@ class SMMClient(object): try: if (not self._wc or (self._wc.vintage and self._wc.vintage < util._monotonic_time() - 30)): - # in case the existing session is still valid, dispose of it - await self.logout() + if not self.updating and self._wc: + # in case the existing session is still valid, dispose + # of it + await self.logout() self._wc = await self.get_webclient() finally: self.weblogging = False From c3d6e0ae5842e1b414991848ab5bee0cab1681ee Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 14:59:06 +0200 Subject: [PATCH 09/13] Clear the firmware poll retry budget after a good poll The counter is there to ride out a few unanswered progress polls, but nothing ever cleared it, so three failures spread across a long apply exhausted it and aborted an update that was still making progress. --- confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py | 1 + 1 file changed, 1 insertion(+) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index 7fa1e451..8b823456 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -1036,6 +1036,7 @@ class SMMClient(object): raise tries += 1 continue + tries = 0 if status != 200: raise Exception('Error applying firmware') progdata = fromstring(progdata) From 659386398894cf3fbf86fdef2d1b3d3dfacab06a Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 14:59:59 +0200 Subject: [PATCH 10/13] Re-establish an SMM web session the chassis has dropped Staleness is judged by age alone, so a session the SMM ended on its own reached the operator as a raw error body instead of being retried. Route the /data calls through a helper that logs back in and retries once on a 401, which is how the SMM answers once a session is gone. A hostname or domain write does not end the session, measured on a DW612S at firmware 1.18, so this covers what the chassis drops by itself, not a self-inflicted loss. --- .../aiohmi/ipmi/oem/lenovo/nextscale.py | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index 8b823456..cba7ad2c 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -411,8 +411,7 @@ class SMMClient(object): async def get_bmc_configuration(self, variant): settings = {} - wc = await self.wc() - rspbody, status, _ = await wc.grab_response_with_status( + rspbody, status = await self.webrequest( '/data', ('get=passwordMinLength,passwordForceChange,passwordDurationDays,' 'passwordExpireWarningDays,passwordChangeInterval,' @@ -695,8 +694,7 @@ class SMMClient(object): changeset[key]['value'])) if rules: rules = 'set={0}'.format(','.join(rules)) - wc = await self.wc() - rsp, status, _ = await wc.grab_response_with_status('/data', rules) + rsp, status = await self.webrequest('/data', rules) if status != 200: raise Exception(rsp) if powercfg != [None, None]: @@ -737,8 +735,7 @@ class SMMClient(object): username = bytes(rsp['data']).rstrip(b'\x00') if not isinstance(username, str): username = username.decode('utf8') - wc = await self.wc() - await wc.grab_response_with_status( + await self.webrequest( '/data', 'set=user({0},1,{1},511,,4,15,0)'.format( uid, username)) @@ -892,8 +889,8 @@ class SMMClient(object): return wc async def set_hostname(self, hostname): - wc = await self.wc() - rsp, status, _ = await wc.grab_response_with_status('/data', 'set=hostname:' + hostname) + rsp, status = await self.webrequest( + '/data', 'set=hostname:' + hostname) if status != 200: raise Exception(rsp) @@ -903,18 +900,17 @@ class SMMClient(object): return data.text async def get_netinfo(self): - wc = await self.wc() - data, status, _ = await wc.grab_response_with_status('/data', 'get=hostname') + data, status = await self.webrequest('/data', 'get=hostname') if status == 400: - data, status, _ = await wc.grab_response_with_status('/data?get=hostname', '') + data, status = await self.webrequest('/data?get=hostname', '') if status != 200: raise Exception(data) currinfo = fromstring(data) return currinfo async def set_domain(self, domain): - wc = await self.wc() - rsp, status, _ = await wc.grab_response_with_status('/data', 'set=dnsDomain:' + domain) + rsp, status = await self.webrequest( + '/data', 'set=dnsDomain:' + domain) if status != 200: raise Exception(rsp) @@ -924,8 +920,7 @@ class SMMClient(object): return data.text async def get_ntp_enabled(self, variant): - wc = await self.wc() - rsp, status, _ = await wc.grab_response_with_status('/data', 'get=ntpOpMode') + rsp, status = await self.webrequest('/data', 'get=ntpOpMode') if status != 200: raise Exception(rsp) info = fromstring(rsp) @@ -933,9 +928,8 @@ class SMMClient(object): return data.text == '1' async def set_ntp_enabled(self, enabled): - wc = await self.wc() - result, status, _ = await wc.grab_response_with_status('/data', 'set=ntpOpMode:{0}'.format( - 1 if enabled else 0)) + result, status = await self.webrequest( + '/data', 'set=ntpOpMode:{0}'.format(1 if enabled else 0)) if status != 200: raise Exception(result) if not isinstance(result, str): @@ -944,9 +938,8 @@ class SMMClient(object): raise Exception("Unrecognized result: " + result) async def set_ntp_server(self, server, index): - wc = await self.wc() - result, status, _ = await wc.grab_response_with_status('/data', 'set=ntpServer{0}:{1}'.format( - index + 1, server)) + result, status = await self.webrequest( + '/data', 'set=ntpServer{0}:{1}'.format(index + 1, server)) if status != 200: raise Exception(result) if not isinstance(result, str): @@ -956,8 +949,7 @@ class SMMClient(object): return True async def get_ntp_servers(self): - wc = await self.wc() - rsp, status, _ = await wc.grab_response_with_status( + rsp, status = await self.webrequest( '/data', 'get=ntpServer1,ntpServer2,ntpServer3') if status != 200: raise Exception(rsp) @@ -1121,3 +1113,13 @@ class SMMClient(object): finally: self.weblogging = False return self._wc + + async def webrequest(self, url, data): + wc = await self.wc() + rsp, status, _ = await wc.grab_response_with_status(url, data) + if status == 401: + # the SMM dropped the session, log back in and try once more + self._wc = None + wc = await self.wc() + rsp, status, _ = await wc.grab_response_with_status(url, data) + return rsp, status From f5a90f3e352f19db029e69e0384196c763703afe Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 15:00:11 +0200 Subject: [PATCH 11/13] Fail set_user_priv on a rejected privilege change Every other /data call checks the status, this one discarded the response, so an SMM that refused the user record was reported to the caller as a successful privilege change. --- confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index cba7ad2c..33c29762 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -735,9 +735,11 @@ class SMMClient(object): username = bytes(rsp['data']).rstrip(b'\x00') if not isinstance(username, str): username = username.decode('utf8') - await self.webrequest( + rsp, status = await self.webrequest( '/data', 'set=user({0},1,{1},511,,4,15,0)'.format( uid, username)) + if status != 200: + raise Exception(rsp) async def reseat_bay(self, bay): bay = int(bay) From 4d75c444cae20280f902510c9f795877b117b26b Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 16:22:00 +0200 Subject: [PATCH 12/13] Ride out a transient bad status while firmware applies The poll loop spends its retry budget on a poll that goes unanswered but aborted the update on the first non-200, even though an SMM restarting its web service part way through the apply keeps answering, with whatever its httpd has to say, before it stops answering at all. Give a bad status the same budget as a dead connection. --- confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index 33c29762..b40bbaf3 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -1030,9 +1030,16 @@ class SMMClient(object): raise tries += 1 continue - tries = 0 if status != 200: - raise Exception('Error applying firmware') + # an SMM restarting its web service part way through the + # apply answers for a while before it stops answering at + # all, so spend the same budget on this as on a poll that + # went unanswered + if tries > 2: + raise Exception('Error applying firmware') + tries += 1 + continue + tries = 0 progdata = fromstring(progdata) if progdata.findall('fwUpdate')[0].text == 'invalid signature': raise Exception('Firmware signature invalid') From ec7b96ecfbbc52bd646f1b54e9e233b90808b3b1 Mon Sep 17 00:00:00 2001 From: Markus Hilger Date: Mon, 27 Jul 2026 16:22:46 +0200 Subject: [PATCH 13/13] Decode SMM response bodies before raising them grab_response_with_status hands back bytes, so every failure path put a b'error' repr in front of the operator rather than what the SMM said. Decode at the raise, replacing rather than failing on a body that is not valid utf8. The bodies still reach fromstring() as bytes, which is what lxml wants when the xml carries an encoding declaration. --- .../aiohmi/ipmi/oem/lenovo/nextscale.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py index b40bbaf3..2458ecbd 100644 --- a/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py +++ b/confluent_server/aiohmi/ipmi/oem/lenovo/nextscale.py @@ -418,7 +418,7 @@ class SMMClient(object): 'passwordReuseCheckNum,passwordFailAllowdNum,' 'passwordLockoutTimePeriod,timeZone')) if status != 200: - raise Exception(rspbody) + raise Exception(rspbody.decode('utf8', 'replace')) accountinfo = fromstring(rspbody) for rule in self.rulemap: ruleinfo = accountinfo.find(self.rulemap[rule]) @@ -696,7 +696,7 @@ class SMMClient(object): rules = 'set={0}'.format(','.join(rules)) rsp, status = await self.webrequest('/data', rules) if status != 200: - raise Exception(rsp) + raise Exception(rsp.decode('utf8', 'replace')) if powercfg != [None, None]: if variant != 6: if None in powercfg: @@ -739,7 +739,7 @@ class SMMClient(object): '/data', 'set=user({0},1,{1},511,,4,15,0)'.format( uid, username)) if status != 200: - raise Exception(rsp) + raise Exception(rsp.decode('utf8', 'replace')) async def reseat_bay(self, bay): bay = int(bay) @@ -842,7 +842,7 @@ class SMMClient(object): ) authdata, status, _ = await wc.grab_response_with_status('/data/login', loginform) if status != 200: - raise Exception(authdata) + raise Exception(authdata.decode('utf8', 'replace')) authdata = fromstring(authdata) for data in authdata.findall('authResult'): if int(data.text) != 0: @@ -861,7 +861,7 @@ class SMMClient(object): # This firmware puts tokens in the html file, parse that indexhtml, status, _ = await wc.grab_response_with_status('/index.html', method='GET') if status != 200: - raise Exception(indexhtml) + raise Exception(indexhtml.decode('utf8', 'replace')) if not isinstance(indexhtml, str): indexhtml = indexhtml.decode('utf8') for line in indexhtml.split('\n'): @@ -874,7 +874,7 @@ class SMMClient(object): if not wc.st2: body, status, _ = await wc.grab_response_with_status('/scripts/index.ajs', method='GET') if status != 200: - raise Exception(body) + raise Exception(body.decode('utf8', 'replace')) if not isinstance(body, str): body = body.decode('utf8') for line in body.split('\n'): @@ -894,7 +894,7 @@ class SMMClient(object): rsp, status = await self.webrequest( '/data', 'set=hostname:' + hostname) if status != 200: - raise Exception(rsp) + raise Exception(rsp.decode('utf8', 'replace')) async def get_hostname(self): currinfo = await self.get_netinfo() @@ -906,7 +906,7 @@ class SMMClient(object): if status == 400: data, status = await self.webrequest('/data?get=hostname', '') if status != 200: - raise Exception(data) + raise Exception(data.decode('utf8', 'replace')) currinfo = fromstring(data) return currinfo @@ -914,7 +914,7 @@ class SMMClient(object): rsp, status = await self.webrequest( '/data', 'set=dnsDomain:' + domain) if status != 200: - raise Exception(rsp) + raise Exception(rsp.decode('utf8', 'replace')) async def get_domain(self): currinfo = await self.get_netinfo() @@ -924,7 +924,7 @@ class SMMClient(object): async def get_ntp_enabled(self, variant): rsp, status = await self.webrequest('/data', 'get=ntpOpMode') if status != 200: - raise Exception(rsp) + raise Exception(rsp.decode('utf8', 'replace')) info = fromstring(rsp) for data in info.findall('ntpOpMode'): return data.text == '1' @@ -933,7 +933,7 @@ class SMMClient(object): result, status = await self.webrequest( '/data', 'set=ntpOpMode:{0}'.format(1 if enabled else 0)) if status != 200: - raise Exception(result) + raise Exception(result.decode('utf8', 'replace')) if not isinstance(result, str): result = result.decode('utf8') if 'ok' not in result: @@ -943,7 +943,7 @@ class SMMClient(object): result, status = await self.webrequest( '/data', 'set=ntpServer{0}:{1}'.format(index + 1, server)) if status != 200: - raise Exception(result) + raise Exception(result.decode('utf8', 'replace')) if not isinstance(result, str): result = result.decode('utf8') if 'ok' not in result: @@ -954,7 +954,7 @@ class SMMClient(object): rsp, status = await self.webrequest( '/data', 'get=ntpServer1,ntpServer2,ntpServer3') if status != 200: - raise Exception(rsp) + raise Exception(rsp.decode('utf8', 'replace')) result = fromstring(rsp) srvs = [] for data in result.findall('ntpServer1'): @@ -991,7 +991,7 @@ class SMMClient(object): rsp, status, _ = await wc.grab_response_with_status( '/data', 'set=fwType:10') # SMM firmware if status != 200: - raise Exception(rsp) + raise Exception(rsp.decode('utf8', 'replace')) url = '/fwupload/fwupload.esp?ST1={0}'.format(wc.st1) fu = await webclient.make_uploader( wc, url, filename, data, formname='fileUpload', @@ -1017,7 +1017,7 @@ class SMMClient(object): rsp, status, _ = await wc.grab_response_with_status( '/data', 'set=fwUpdate:1') if status != 200: - raise Exception(rsp) + raise Exception(rsp.decode('utf8', 'replace')) complete = False tries = 0 while not complete: